Skip to content
Permalink

Comparing changes

Choose two branches to see what’s changed or to start a new pull request. If you need to, you can also or learn more about diff comparisons.

Open a pull request

Create a new pull request by comparing changes across two branches. If you need to, you can also . Learn more about diff comparisons here.
base repository: decentraland/decentraland-dapps
Failed to load repositories. Confirm that selected base ref is valid, then try again.
Loading
base: v23.17.1
Choose a base ref
...
head repository: decentraland/decentraland-dapps
Failed to load repositories. Confirm that selected head ref is valid, then try again.
Loading
compare: master
Choose a head ref
  • 10 commits
  • 30 files changed
  • 3 contributors

Commits on Jan 6, 2025

  1. feat: Navbar2 from UI2 (#657)

    * feat: Navbar2 from UI2
    
    * feat: update decentraland-ui2 v 0.8.3
    braianj authored Jan 6, 2025
    Copy the full SHA
    f0a8de7 View commit details
  2. Copy the full SHA
    cf5325f View commit details

Commits on Jan 7, 2025

  1. feat: update decentraland-ui2 v0.8.5 (#659)

    * feat: ui2 0.8.5
    
    * feat: remove packs
    braianj authored Jan 7, 2025
    Copy the full SHA
    42f33f4 View commit details
  2. Copy the full SHA
    e922719 View commit details

Commits on Jan 8, 2025

  1. Copy the full SHA
    8510756 View commit details

Commits on Jan 13, 2025

  1. Copy the full SHA
    e81c108 View commit details

Commits on Jan 14, 2025

  1. fix: apply fibonacci backoff delay and threshold (#663)

    * fix: apply fibonacci delay and threshold
    
    * chore: added tests
    
    * chore: address feedback
    cazala authored Jan 14, 2025
    Copy the full SHA
    114d360 View commit details

Commits on Jan 20, 2025

  1. feat: Add Campaign module and Banner component (#664)

    * feat: Add Campaign module and Banner component
    
    * fix: Types
    
    * fix: Small fixes
    
    * fix: Removed changed version
    
    * fix: Versioning
    LautaroPetaccio authored Jan 20, 2025
    Copy the full SHA
    25cd94f View commit details

Commits on Jan 22, 2025

  1. feat: Add tracking to banner button (#665)

    * feat: Add tracking to banner button
    
    * fix: Package.json
    
    * fix: Move onClick handler to a component
    LautaroPetaccio authored Jan 22, 2025
    Copy the full SHA
    a3a9b2e View commit details

Commits on Jan 27, 2025

  1. Copy the full SHA
    1521cc4 View commit details
947 changes: 861 additions & 86 deletions package-lock.json

Large diffs are not rendered by default.

7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -10,7 +10,7 @@
"@0xsequence/multicall": "^0.25.1",
"@0xsequence/relayer": "^0.25.1",
"@dcl/crypto": "^3.3.1",
"@dcl/schemas": "^15.1.2",
"@dcl/schemas": "^15.6.0",
"@dcl/single-sign-on-client": "^0.1.0",
"@dcl/ui-env": "^1.5.0",
"@transak/transak-sdk": "^3.1.3",
@@ -25,6 +25,7 @@
"decentraland-crypto-fetch": "^2.0.1",
"decentraland-transactions": "^2.18.0",
"decentraland-ui": "^6.11.0",
"decentraland-ui2": "^0.10.0",
"ethers": "^5.6.8",
"events": "^3.3.0",
"flat": "^5.0.2",
@@ -45,8 +46,8 @@
"commitmsg": "validate-commit-msg"
},
"devDependencies": {
"@swc/core": "^1.3.107",
"@swc/jest": "^0.2.31",
"@swc/core": "^1.10.7",
"@swc/jest": "^0.2.37",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^12.1.5",
"@testing-library/user-event": "^12.1.5",
20 changes: 20 additions & 0 deletions src/containers/Banner/Banner.container.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { connect } from 'react-redux'
import {
getBanner,
getBannerAssets,
getContentfulNormalizedLocale,
getError,
isLoading
} from '../../modules/campaign'
import { MapStateProps, OwnProps } from './Banner.types'
import { Banner } from './Banner'

const mapState = (state: any, ownProps: OwnProps): MapStateProps => ({
fields: getBanner(state, ownProps.id) ?? null,
assets: getBannerAssets(state, ownProps.id),
isLoading: isLoading(state),
error: getError(state),
locale: getContentfulNormalizedLocale(state)
})

export default connect<MapStateProps, {}, OwnProps>(mapState)(Banner)
32 changes: 32 additions & 0 deletions src/containers/Banner/Banner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import React, { useCallback } from 'react'
import { Banner as BannerComponent } from 'decentraland-ui2'
import { getAnalytics } from '../../modules/analytics/utils'
import { BannerProps } from './Banner.types'
import { sleep } from '../../lib/time'

export const Banner: React.FC<BannerProps> = (props: BannerProps) => {
const { fields, id, ...rest } = props
const handleClick = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
const anchorEvent = (event as unknown) as React.MouseEvent<
HTMLAnchorElement
>
anchorEvent.preventDefault()

const analytics = getAnalytics()
if (analytics) {
analytics.track('CLICK_BANNER', {
id: fields?.id,
location: id
})
}
// Delay the navigation to allow the analytics to be tracked
sleep(300).then(() => {
window.location.href = (anchorEvent.target as HTMLAnchorElement).href
})
},
[fields?.id, id]
)

return <BannerComponent onClick={handleClick} fields={fields} {...rest} />
}
11 changes: 11 additions & 0 deletions src/containers/Banner/Banner.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { BannerFields } from '@dcl/schemas'
import { BannerProps as UIBannerProps } from 'decentraland-ui2'

export type BannerProps = Omit<UIBannerProps, 'fields'> & {
fields: (BannerFields & { id: string }) | null
} & { id: string }
export type OwnProps = Pick<BannerProps, 'id'>
export type MapStateProps = Pick<
BannerProps,
'fields' | 'assets' | 'isLoading' | 'error' | 'locale'
>
4 changes: 4 additions & 0 deletions src/containers/Banner/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import Banner from './Banner.container'

export { Banner }
export * from './Banner.types'
17 changes: 17 additions & 0 deletions src/containers/Navbar/Navbar.types.tsx
Original file line number Diff line number Diff line change
@@ -2,6 +2,7 @@ import { Dispatch } from 'redux'
import { ChainId } from '@dcl/schemas/dist/dapps/chain-id'
import { AuthIdentity } from '@dcl/crypto'
import { NavbarProps as NavbarComponentProps } from 'decentraland-ui/dist/components/Navbar/Navbar.types'
import { NavbarProps as NavbarComponentProps2 } from 'decentraland-ui2'
import {
disconnectWalletRequest,
switchNetworkRequest,
@@ -24,6 +25,22 @@ export type NavbarProps = NavbarComponentProps & {
onSignIn: () => void
}

export type NavbarProps2 = NavbarComponentProps2 & {
withChainSelector?: boolean
chainId?: ChainId
appChainId: ChainId
docsUrl?: string
enablePartialSupportAlert?: boolean
isSwitchingNetwork?: boolean
withNotifications?: boolean
identity?: AuthIdentity
locale: string
walletError: string | null
onSwitchNetwork: typeof switchNetworkRequest
onSignOut: typeof disconnectWalletRequest
onSignIn: () => void
}

export type MapStateProps = Pick<
NavbarProps,
| 'manaBalances'
58 changes: 58 additions & 0 deletions src/containers/Navbar/Navbar2.container.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { connect } from 'react-redux'
import {
isConnected,
isConnecting,
getAddress,
getChainId,
getAppChainId,
getManaBalances,
isSwitchingNetwork,
isDisconnecting
} from '../../modules/wallet/selectors'
import { getData as getProfiles } from '../../modules/profile/selectors'
import { getError as getWalletError } from '../../modules/wallet/selectors'
import { getLocale } from '../../modules/translation/selectors'
import {
disconnectWalletRequest,
switchNetworkRequest
} from '../../modules/wallet/actions'
import { RootDispatch } from '../../types'
import { NavbarProps2, MapStateProps, MapDispatchProps } from './Navbar.types'
import Navbar2 from './Navbar2'
import { ChainId } from '@dcl/schemas'

const mapState = (state: any): MapStateProps => {
const address = getAddress(state)
const profile = address ? getProfiles(state)[address] : undefined
return {
avatar: profile ? profile.avatars[0] : undefined,
chainId: getChainId(state),
manaBalances: getManaBalances(state),
address: getAddress(state),
locale: getLocale(state),
isSignedIn: isConnected(state),
isDisconnecting: isDisconnecting(state),
isSigningIn: isConnecting(state),
appChainId: getAppChainId(state),
isSwitchingNetwork: isSwitchingNetwork(state),
walletError: getWalletError(state)
}
}

const mapDispatch = (dispatch: RootDispatch): MapDispatchProps => ({
onSwitchNetwork: (chainId: ChainId, fromChainId: ChainId) =>
dispatch(switchNetworkRequest(chainId, fromChainId)),
onSignOut: () => dispatch(disconnectWalletRequest())
})

const mergeProps = (
stateProps: MapStateProps,
dispatchProps: MapDispatchProps,
ownProps: NavbarProps2
): NavbarProps2 => ({
...stateProps,
...dispatchProps,
...ownProps
})

export default connect(mapState, mapDispatch, mergeProps)(Navbar2) as any
8 changes: 8 additions & 0 deletions src/containers/Navbar/Navbar2.styled.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { Box, styled } from 'decentraland-ui2'

const NavbarContainer = styled(Box)({
paddingTop: '66px',
width: '100%'
})

export { NavbarContainer }
200 changes: 200 additions & 0 deletions src/containers/Navbar/Navbar2.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import React, { useCallback, useEffect, useState } from 'react'
import { Navbar as NavbarComponent } from 'decentraland-ui2'
import { NotificationLocale } from 'decentraland-ui/dist/components/Notifications/types'
import { ChainId, getChainName } from '@dcl/schemas/dist/dapps/chain-id'
import { ProviderType } from '@dcl/schemas'
import { Network } from '@dcl/schemas/dist/dapps/network'
import { getAnalytics } from '../../modules/analytics/utils'
import { t } from '../../modules/translation'
import UnsupportedNetworkModal from '../UnsupportedNetworkModal'
import { getAvailableChains } from '../../lib/chainConfiguration'
import { getConnectedProviderType } from '../../lib'
import { getBaseUrl } from '../../lib/utils'
import ChainProvider from '../ChainProvider'
import {
CHANGE_NETWORK,
DROPDOWN_MENU_BALANCE_CLICK_EVENT,
DROPDOWN_MENU_DISPLAY_EVENT,
DROPDOWN_MENU_ITEM_CLICK_EVENT,
DROPDOWN_MENU_SIGN_OUT_EVENT
} from './constants'
import { NavbarProps2 } from './Navbar.types'
import { NAVBAR_CLICK_EVENT } from './constants'
import useNotifications from '../../hooks/useNotifications'
import { NavbarContainer } from './Navbar2.styled'

const BASE_URL = getBaseUrl()

const Navbar2: React.FC<NavbarProps2> = ({
appChainId,
isSwitchingNetwork,
withNotifications,
withChainSelector,
identity,
docsUrl = 'https://docs.decentraland.org',
enablePartialSupportAlert = true,
walletError,
...props
}: NavbarProps2) => {
const expectedChainName = getChainName(appChainId)
const analytics = getAnalytics()

const {
isModalOpen,
isNotificationsOnboarding,
modalActiveTab,
isLoading,
notifications,
handleNotificationsOpen,
handleOnBegin,
handleOnChangeModalTab,
handleRenderProfile
} = useNotifications(identity, withNotifications || false)

const handleSwitchNetwork = useCallback(() => {
props.onSwitchNetwork(appChainId)
}, [])

const [chainSelected, setChainSelected] = useState<ChainId | undefined>(
undefined
)

useEffect(() => {
if (walletError && chainSelected && withChainSelector) {
setChainSelected(undefined)
}
}, [walletError, chainSelected, withChainSelector])

const handleSwitchChain = useCallback(
(chainId: ChainId) => {
setChainSelected(chainId)
props.onSwitchNetwork(chainId, props.chainId)
analytics.track(CHANGE_NETWORK, {
from_chain_id: props.chainId,
to_chain_id: chainId
})
},
[analytics]
)

const handleClickBalance = useCallback(
(
e: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement, MouseEvent>,
network?: Network
) => {
e.preventDefault()
analytics.track(DROPDOWN_MENU_BALANCE_CLICK_EVENT, { network })

setTimeout(() => {
window.open(`${BASE_URL}/account`, '_blank', 'noopener')
}, 300)
},
[analytics]
)

const handleClickNavbarItem = useCallback(
(
_e: React.MouseEvent,
options: { eventTrackingName: string; url?: string; isExternal?: boolean }
) => {
analytics.track(NAVBAR_CLICK_EVENT, options)
},
[analytics]
)

const handleClickUserMenuItem = useCallback(
(
_e: React.MouseEvent,
options: { type: string; url?: string; track_uuid?: string }
) => {
analytics.track(DROPDOWN_MENU_ITEM_CLICK_EVENT, options)
},
[analytics]
)

const handleClickOpen = useCallback(
(_e: React.MouseEvent, track_uuid: string) => {
analytics.track(DROPDOWN_MENU_DISPLAY_EVENT, { track_uuid })
},
[analytics]
)

const handleClickSignIn = useCallback(
(_e: React.MouseEvent<HTMLElement, MouseEvent>) => {
props.onSignIn()
},
[analytics]
)

const handleClickSignOut = useCallback(
(_e: React.MouseEvent<HTMLElement, MouseEvent>, track_uuid: string) => {
analytics.track(DROPDOWN_MENU_SIGN_OUT_EVENT, { track_uuid })
setTimeout(() => {
props.onSignOut()
}, 300)
},
[analytics]
)

return (
<NavbarContainer>
<ChainProvider>
{({ chainId, isUnsupported }) => (
<>
<NavbarComponent
{...props}
notifications={
withNotifications
? {
locale: props.locale as NotificationLocale,
isLoading,
isOnboarding: isNotificationsOnboarding,
isOpen: isModalOpen,
items: notifications,
activeTab: modalActiveTab,
onClick: handleNotificationsOpen,
onClose: handleNotificationsOpen,
onBegin: handleOnBegin,
onChangeTab: (_, tab) => handleOnChangeModalTab(tab),
renderProfile: handleRenderProfile
}
: undefined
}
onClickBalance={handleClickBalance}
onClickNavbarItem={handleClickNavbarItem}
onClickUserMenuItem={handleClickUserMenuItem}
onClickOpen={handleClickOpen}
onClickSignIn={handleClickSignIn}
onClickSignOut={handleClickSignOut}
{...(withChainSelector && {
chains: getAvailableChains(),
selectedChain: chainId ?? undefined,
chainBeingConfirmed:
chainSelected !== chainId ? chainSelected : undefined,
onSelectChain: handleSwitchChain,
i18nChainSelector: {
title: t('@dapps.chain_selector.title'),
connected: t('@dapps.chain_selector.connected'),
confirmInWallet:
getConnectedProviderType() === ProviderType.INJECTED // for injected ones, show label to confirm in wallet, the rest won't ask for confirmation
? t('@dapps.chain_selector.confirm_in_wallet')
: t('@dapps.chain_selector.switching')
}
})}
/>
{isUnsupported ? (
<UnsupportedNetworkModal
chainName={getChainName(chainId!)}
expectedChainName={expectedChainName!}
isSwitchingNetwork={isSwitchingNetwork}
onSwitchNetwork={handleSwitchNetwork}
/>
) : null}
</>
)}
</ChainProvider>
</NavbarContainer>
)
}

export default React.memo(Navbar2)
3 changes: 3 additions & 0 deletions src/containers/Navbar/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
import Navbar from './Navbar.container'
import Navbar2 from './Navbar2.container'

export default Navbar
export { Navbar2 }
1 change: 1 addition & 0 deletions src/containers/index.ts
Original file line number Diff line number Diff line change
@@ -5,6 +5,7 @@ export { default as Footer } from './Footer'
export { default as LoginModal } from './LoginModal'
export { default as Modal } from './Modal'
export { default as Navbar } from './Navbar'
export { Navbar2 } from './Navbar'
export { default as NetworkButton } from './NetworkButton'
export { default as NetworkCheck } from './NetworkCheck'
export { default as Profile } from './Profile'
6 changes: 6 additions & 0 deletions src/lib/time.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
export function fromMillisecondsToSeconds(timeInMilliseconds: number): number {
return Math.floor(timeInMilliseconds / 1000)
}

export function sleep(delay: number) {
return new Promise(resolve => {
setTimeout(resolve, delay)
})
}
87 changes: 87 additions & 0 deletions src/modules/campaign/ContentfulClient.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { ContentfulResponse } from '@dcl/schemas'
import { ContentfulClient } from './ContentfulClient'
import { mockHomepageBannerEntry } from '../../tests/contentfulMocks'

describe('ContentfulClient', () => {
let client: ContentfulClient

beforeEach(() => {
client = new ContentfulClient()
})

describe('fetchEntry', () => {
const mockSpace = 'test-space'
const mockEnvironment = 'test-env'
const mockId = 'test-id'
const mockContentType = 'test-content-type'
const mockToken = 'test-token'

describe('when the request is successful', () => {
let mockResponse: ContentfulResponse<any>
beforeEach(() => {
mockResponse = {
items: [{ ...mockHomepageBannerEntry }],
includes: {
Asset: [],
Entry: []
}
}
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockResponse)
})
})

it('should have fetched the entry with the given parameters', async () => {
await client.fetchEntry(
mockSpace,
mockEnvironment,
mockId,
mockContentType,
mockToken
)

expect(fetch).toHaveBeenCalledWith(
`https://cdn.contentful.com/spaces/${mockSpace}/environments/${mockEnvironment}/entries/?sys.id=${mockId}&content_type=${mockContentType}&locale=*`,
{
headers: {
Authorization: `Bearer ${mockToken}`
}
}
)
})

it('should return the parsed response', async () => {
const result = await client.fetchEntry(
mockSpace,
mockEnvironment,
mockId,
mockContentType,
mockToken
)

expect(result).toEqual(mockResponse)
})
})

describe('when the request fails', () => {
beforeEach(() => {
global.fetch = jest.fn().mockResolvedValue({
ok: false
})
})

it('should throw an error', async () => {
await expect(
client.fetchEntry(
mockSpace,
mockEnvironment,
mockId,
mockContentType,
mockToken
)
).rejects.toThrow('Failed to fetch entity data')
})
})
})
})
42 changes: 42 additions & 0 deletions src/modules/campaign/ContentfulClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import {
ContentfulResponse,
LocalizedField,
LocalizedFieldType
} from '@dcl/schemas'
import { BaseClient } from '../../lib'

export class ContentfulClient extends BaseClient {
constructor() {
super('https://cdn.contentful.com')
}

async fetchEntry<
T extends Record<string, LocalizedField<LocalizedFieldType>>
>(
space: string,
environment: string,
id: string,
contentType: string,
token: string
): Promise<ContentfulResponse<T>> {
const response = await this.rawFetch(
`/spaces/${space}/environments/${environment}/entries/?` +
new URLSearchParams({
'sys.id': id,
content_type: contentType,
locale: '*'
}),
{
headers: {
Authorization: `Bearer ${token}`
}
}
)

if (!response.ok) {
throw new Error('Failed to fetch entity data')
}

return response.json()
}
}
100 changes: 100 additions & 0 deletions src/modules/campaign/actions.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { ContentfulAsset, BannerFields } from '@dcl/schemas'
import {
marketplaceHomepageBannerAssets,
mockHomepageBannerEntry
} from '../../tests/contentfulMocks'
import {
fetchCampaignRequest,
fetchCampaignSuccess,
fetchCampaignFailure,
FETCH_CAMPAIGN_REQUEST,
FETCH_CAMPAIGN_SUCCESS,
FETCH_CAMPAIGN_FAILURE
} from './actions'

describe('Campaign actions', () => {
describe('when fetching campaign data', () => {
describe('when calling the request action creator', () => {
let action: ReturnType<typeof fetchCampaignRequest>
let expectedAction: ReturnType<typeof fetchCampaignRequest>

beforeEach(() => {
action = fetchCampaignRequest()
expectedAction = {
type: FETCH_CAMPAIGN_REQUEST
}
})

it('should return the request action', () => {
expect(action).toEqual(expectedAction)
})
})

describe('when calling the success action creator', () => {
let action: ReturnType<typeof fetchCampaignSuccess>
let expectedAction: ReturnType<typeof fetchCampaignSuccess>

beforeEach(() => {
const name = {
'en-US': 'Test Campaign'
}
const tabName = {
'en-US': 'Test Tab'
}
const mainTag = 'main-tag'
const additionalTags = ['tag1', 'tag2']
const banners: Record<string, BannerFields> = {
[mockHomepageBannerEntry.sys.id]: mockHomepageBannerEntry.fields
}
const assets: Record<string, ContentfulAsset> = {
[marketplaceHomepageBannerAssets[0].sys.id]:
marketplaceHomepageBannerAssets[0]
}
action = fetchCampaignSuccess(
banners,
assets,
name,
tabName,
mainTag,
additionalTags
)
expectedAction = {
type: FETCH_CAMPAIGN_SUCCESS,
payload: {
name,
tabName,
mainTag,
additionalTags,
banners,
assets
}
}
})

it('should return the success action with the campaign data', () => {
expect(action).toEqual(expectedAction)
})
})

describe('when calling the failure action creator', () => {
let action: ReturnType<typeof fetchCampaignFailure>
let expectedAction: ReturnType<typeof fetchCampaignFailure>
let error: string

beforeEach(() => {
error = 'Failed to fetch campaign'
action = fetchCampaignFailure(error)
expectedAction = {
type: FETCH_CAMPAIGN_FAILURE,
payload: {
error
}
}
})

it('should return the failure action with the error', () => {
expect(action).toEqual(expectedAction)
})
})
})
})
32 changes: 32 additions & 0 deletions src/modules/campaign/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { action } from 'typesafe-actions'
import { BannerFields, ContentfulAsset, LocalizedField } from '@dcl/schemas'

export const FETCH_CAMPAIGN_REQUEST = '[Request] Fetch Campaign'
export const FETCH_CAMPAIGN_SUCCESS = '[Success] Fetch Campaign'
export const FETCH_CAMPAIGN_FAILURE = '[Failure] Fetch Campaign'

export const fetchCampaignRequest = () => action(FETCH_CAMPAIGN_REQUEST)

export const fetchCampaignSuccess = (
banners: Record<string, BannerFields & { id: string }>,
assets: Record<string, ContentfulAsset>,
name?: LocalizedField<string>,
tabName?: LocalizedField<string>,
mainTag?: string,
additionalTags?: string[]
) =>
action(FETCH_CAMPAIGN_SUCCESS, {
name,
tabName,
mainTag,
additionalTags,
banners,
assets
})

export const fetchCampaignFailure = (error: string) =>
action(FETCH_CAMPAIGN_FAILURE, { error })

export type FetchCampaignRequestAction = ReturnType<typeof fetchCampaignRequest>
export type FetchCampaignSuccessAction = ReturnType<typeof fetchCampaignSuccess>
export type FetchCampaignFailureAction = ReturnType<typeof fetchCampaignFailure>
6 changes: 6 additions & 0 deletions src/modules/campaign/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export * from './actions'
export * from './reducer'
export * from './sagas'
export * from './selectors'
export * from './types'
export * from './ContentfulClient'
118 changes: 118 additions & 0 deletions src/modules/campaign/reducer.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { loadingReducer } from '../loading/reducer'
import {
fetchCampaignRequest,
fetchCampaignSuccess,
fetchCampaignFailure
} from './actions'
import { campaignReducer } from './reducer'
import { CampaignState } from './types'

let initialState: CampaignState

describe('Campaign reducer', () => {
beforeEach(() => {
initialState = {
data: null,
loading: [],
error: null
}
})

describe('when handling the fetch campaign request action', () => {
let state: CampaignState
let action: ReturnType<typeof fetchCampaignRequest>

beforeEach(() => {
action = fetchCampaignRequest()
const stateWithError: CampaignState = {
...initialState,
error: 'Some previous error'
}
state = campaignReducer(stateWithError, action)
})

it('should add the action to the loading state and clear the error', () => {
expect(state).toEqual({
data: null,
loading: loadingReducer([], action),
error: null
})
})
})

describe('when handling the fetch campaign success action', () => {
let state: CampaignState
let requestAction: ReturnType<typeof fetchCampaignRequest>
let successAction: ReturnType<typeof fetchCampaignSuccess>
let campaignData: CampaignState['data']

beforeEach(() => {
requestAction = fetchCampaignRequest()
campaignData = {
name: { 'en-US': 'Test Name' },
tabName: { 'en-US': 'Test Tab' },
mainTag: 'main-tag',
additionalTags: ['tag1', 'tag2'],
banners: {},
assets: {}
}

successAction = fetchCampaignSuccess(
campaignData.banners,
campaignData.assets,
campaignData.name,
campaignData.tabName,
campaignData.mainTag,
campaignData.additionalTags
)

const loadingState = campaignReducer(initialState, requestAction)
state = campaignReducer(loadingState, successAction)
})

it('should update the data and remove the loading state', () => {
expect(state).toEqual({
data: campaignData,
loading: loadingReducer([requestAction], successAction),
error: null
})
})
})

describe('when handling the fetch campaign failure action', () => {
let state: CampaignState
let requestAction: ReturnType<typeof fetchCampaignRequest>
let failureAction: ReturnType<typeof fetchCampaignFailure>
let errorMessage: string

beforeEach(() => {
requestAction = fetchCampaignRequest()
errorMessage = 'Failed to fetch campaign'
failureAction = fetchCampaignFailure(errorMessage)

const loadingState = campaignReducer(initialState, requestAction)
state = campaignReducer(loadingState, failureAction)
})

it('should set the error and remove the loading state', () => {
expect(state).toEqual({
data: null,
loading: loadingReducer([requestAction], failureAction),
error: errorMessage
})
})
})

describe('when handling an unknown action', () => {
let state: CampaignState

beforeEach(() => {
const action = { type: 'UNKNOWN_ACTION' }
state = campaignReducer(initialState, action as any)
})

it('should return the same state', () => {
expect(state).toEqual(initialState)
})
})
})
45 changes: 45 additions & 0 deletions src/modules/campaign/reducer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { loadingReducer } from '../loading/reducer'
import { CampaignState, CampaignAction } from './types'
import {
FETCH_CAMPAIGN_REQUEST,
FETCH_CAMPAIGN_SUCCESS,
FETCH_CAMPAIGN_FAILURE
} from './actions'

const INITIAL_STATE: CampaignState = {
data: null,
loading: [],
error: null
}

export function campaignReducer(
state = INITIAL_STATE,
action: CampaignAction
): CampaignState {
switch (action.type) {
case FETCH_CAMPAIGN_REQUEST: {
return {
...state,
loading: loadingReducer(state.loading, action),
error: null
}
}
case FETCH_CAMPAIGN_SUCCESS: {
return {
...state,
loading: loadingReducer(state.loading, action),
data: action.payload,
error: null
}
}
case FETCH_CAMPAIGN_FAILURE: {
return {
...state,
loading: loadingReducer(state.loading, action),
error: action.payload.error
}
}
default:
return state
}
}
144 changes: 144 additions & 0 deletions src/modules/campaign/sagas.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { expectSaga } from 'redux-saga-test-plan'
import * as matchers from 'redux-saga-test-plan/matchers'
import { throwError } from 'redux-saga-test-plan/providers'
import { ContentfulResponse, MarketingAdminFields } from '@dcl/schemas'
import {
mockAdminEntry,
marketplaceHomepageBannerAssets,
mockCampaignEntry,
mockHomepageBannerEntry
} from '../../tests/contentfulMocks'
import { ContentfulClient } from './ContentfulClient'
import { campaignSagas } from './sagas'
import {
fetchCampaignRequest,
fetchCampaignSuccess,
fetchCampaignFailure
} from './actions'

describe('when handling the fetch campaign request', () => {
let mockConfig: {
space: string
environment: string
id: string
token: string
}
let mockClient: ContentfulClient
let mockResponse: ContentfulResponse<MarketingAdminFields>

beforeEach(() => {
mockConfig = {
space: 'space-id',
environment: 'environment-id',
id: 'entry-id',
token: 'access-token'
}
mockClient = new ContentfulClient()
})

describe('when the request is successful', () => {
beforeEach(() => {
mockResponse = {
items: [{ ...mockAdminEntry }],
includes: {
Asset: [...marketplaceHomepageBannerAssets],
Entry: [{ ...mockHomepageBannerEntry }, { ...mockCampaignEntry }]
}
}
})

it('should put fetch campaign success with the transformed campaign data', () => {
return expectSaga(campaignSagas, mockClient, mockConfig)
.provide([
[
matchers.call(
[mockClient, 'fetchEntry'],
mockConfig.space,
mockConfig.environment,
mockConfig.id,
'admin',
mockConfig.token
),
mockResponse
]
])
.put(
fetchCampaignSuccess(
{
marketplaceHomepageBanner: {
...mockHomepageBannerEntry.fields,
id: mockHomepageBannerEntry.sys.id
}
},
{
[marketplaceHomepageBannerAssets[0].sys.id]:
marketplaceHomepageBannerAssets[0],
[marketplaceHomepageBannerAssets[1].sys.id]:
marketplaceHomepageBannerAssets[1],
[marketplaceHomepageBannerAssets[2].sys.id]:
marketplaceHomepageBannerAssets[2]
},
mockCampaignEntry.fields.name,
mockCampaignEntry.fields.marketplaceTabName,
mockCampaignEntry.fields.mainTag?.['en-US'],
mockCampaignEntry.fields.additionalTags?.['en-US']
)
)
.dispatch(fetchCampaignRequest())
.run()
})
})

describe('when the request fails', () => {
it('should put fetch campaign failure with the error message', () => {
const error = new Error('Network error')

return expectSaga(campaignSagas, mockClient, mockConfig)
.provide([
[
matchers.call(
[mockClient, 'fetchEntry'],
mockConfig.space,
mockConfig.environment,
mockConfig.id,
'admin',
mockConfig.token
),
throwError(error)
]
])
.put(fetchCampaignFailure('Network error'))
.dispatch(fetchCampaignRequest())
.run()
})
})

describe('when the response contains no items', () => {
beforeEach(() => {
mockResponse = {
items: [],
includes: {}
}
})

it('should put fetch campaign failure with an error message', () => {
return expectSaga(campaignSagas, mockClient, mockConfig)
.provide([
[
matchers.call(
[mockClient, 'fetchEntry'],
mockConfig.space,
mockConfig.environment,
mockConfig.id,
'admin',
mockConfig.token
),
mockResponse
]
])
.put(fetchCampaignFailure('Failed to fetch campaign data'))
.dispatch(fetchCampaignRequest())
.run()
})
})
})
117 changes: 117 additions & 0 deletions src/modules/campaign/sagas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { call, put, takeEvery } from 'redux-saga/effects'
import {
BannerFields,
CampaignFields,
ContentfulAsset,
ContentfulEntry,
ContentfulResponse,
ContentfulLocale,
LocalizedField,
LocalizedFields,
MarketingAdminFields,
SysLink,
isSysLink
} from '@dcl/schemas'
import { isErrorWithMessage } from '../../lib'
import { FETCH_CAMPAIGN_REQUEST, FetchCampaignRequestAction } from './actions'
import { fetchCampaignSuccess, fetchCampaignFailure } from './actions'
import { ContentfulClient } from './ContentfulClient'

const ADMIN_CONTENT_TYPE = 'admin'
const BANNER_CONTENT_TYPE = 'banner'
const MARKETING_CAMPAIGN_CONTENT_TYPE = 'marketingCampaign'

export function* campaignSagas(
client: ContentfulClient,
config: {
space: string
environment: string
id: string
token: string
}
) {
yield takeEvery(FETCH_CAMPAIGN_REQUEST, handleFetchCampaignRequest)

function* handleFetchCampaignRequest(_: FetchCampaignRequestAction) {
try {
const { items, includes } = (yield call(
[client, 'fetchEntry'],
config.space,
config.environment,
config.id,
ADMIN_CONTENT_TYPE,
config.token
)) as ContentfulResponse<MarketingAdminFields>

if (!items || (items && items.length === 0)) {
throw new Error('Failed to fetch campaign data')
}

const assets = (includes.Asset ?? []).reduce((acc, asset) => {
acc[asset.sys.id] = asset
return acc
}, {} as Record<string, ContentfulAsset>)

const entries = (includes.Entry ?? []).reduce((acc, entry) => {
acc[entry.sys.id] = entry
return acc
}, {} as Record<string, ContentfulEntry<LocalizedFields>>)

const banners = Object.entries(items[0].fields).reduce(
(acc, [key, value]) => {
const fieldOnLocale = value[ContentfulLocale.enUS]
if (isSysLink(fieldOnLocale)) {
const linkedEntryId = fieldOnLocale.sys.id
const bannerEntry = entries[linkedEntryId]
if (
bannerEntry &&
bannerEntry.sys.contentType.sys.id === BANNER_CONTENT_TYPE
) {
acc[key] = {
...bannerEntry.fields,
id: linkedEntryId
} as BannerFields & { id: string }
}
}
return acc
},
{} as Record<string, BannerFields & { id: string }>
)

const campaignField = Object.values(items[0].fields).find(field => {
const fieldOnLocale = field[ContentfulLocale.enUS]
if (isSysLink(fieldOnLocale)) {
const entry = entries[fieldOnLocale.sys.id]
if (
entry.sys.contentType.sys.id === MARKETING_CAMPAIGN_CONTENT_TYPE
) {
return entry.fields as CampaignFields
}
}
return false
}) as LocalizedField<SysLink<'Entry'>> | undefined

const campaignFields = campaignField?.[ContentfulLocale.enUS].sys.id
? (entries[campaignField?.[ContentfulLocale.enUS].sys.id]
?.fields as CampaignFields)
: undefined

yield put(
fetchCampaignSuccess(
banners,
assets,
campaignFields?.name,
campaignFields?.marketplaceTabName,
campaignFields?.mainTag?.[ContentfulLocale.enUS],
campaignFields?.additionalTags?.[ContentfulLocale.enUS]
)
)
} catch (error) {
yield put(
fetchCampaignFailure(
isErrorWithMessage(error) ? error.message : 'Error fetching campaign'
)
)
}
}
}
390 changes: 390 additions & 0 deletions src/modules/campaign/selectors.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,390 @@
import { ContentfulLocale, ContentfulAsset, BannerFields } from '@dcl/schemas'
import { TranslationState } from '../translation/reducer'
import { fetchCampaignRequest } from './actions'
import {
getState,
getData,
getLoading,
getError,
isLoading,
getMainTag,
getAllTags,
getAssets,
getTabName,
getBanner,
getBannerAssets,
getCampaignName,
getAdditionalTags,
getContentfulNormalizedLocale
} from './selectors'
import { CampaignState } from './types'

type MockState = {
campaign: CampaignState
translation: TranslationState
}

let mockState: MockState
let mockAsset: ContentfulAsset
let mockBanner: BannerFields & { id: string }

describe('Campaign selectors', () => {
beforeEach(() => {
mockAsset = {
metadata: {
tags: [],
concepts: []
},
sys: {
id: 'asset1',
type: 'Asset',
space: { sys: { type: 'Link', linkType: 'Space', id: 'space1' } },
createdAt: '2021-01-01',
updatedAt: '2021-01-01',
environment: {
sys: { type: 'Link', linkType: 'Environment', id: 'env1' }
},
publishedVersion: 1,
revision: 1
},
fields: {
title: {
'en-US': 'Test Banner'
},
description: {
'en-US': 'Test Description'
},
file: {
'en-US': {
url: 'test-url',
details: {
size: 123
},
fileName: 'test.png',
contentType: 'image/png'
}
}
}
}

mockBanner = {
id: 'someId',
fullSizeBackground: {
'en-US': {
sys: {
type: 'Link',
linkType: 'Asset',
id: 'asset1'
}
}
}
} as BannerFields & { id: string }

mockState = {
campaign: {
data: {
name: {
'en-US': 'Test Campaign'
},
tabName: {
'en-US': 'testTab'
},
mainTag: 'main',
additionalTags: ['tag1', 'tag2'],
banners: {
banner1: mockBanner
},
assets: {
asset1: mockAsset
}
},
loading: [],
error: null
},
translation: {
data: {},
locale: 'en',
loading: [],
error: null
}
}
})

describe('when getting the campaign state', () => {
it('should return the campaign state', () => {
expect(getState(mockState)).toEqual(mockState.campaign)
})
})

describe('when getting the campaign data', () => {
describe('and the data exists', () => {
it('should return the campaign data', () => {
expect(getData(mockState)).toEqual(mockState.campaign.data)
})
})

describe('and there is no data', () => {
beforeEach(() => {
mockState.campaign.data = null
})

it('should return null', () => {
expect(getData(mockState)).toBeNull()
})
})
})

describe('when getting the loading state', () => {
it('should return the loading state', () => {
expect(getLoading(mockState)).toEqual(mockState.campaign.loading)
})
})

describe('when getting the error state', () => {
describe('and there is no error', () => {
it('should return null', () => {
expect(getError(mockState)).toBeNull()
})
})

describe('and there is an error', () => {
beforeEach(() => {
mockState.campaign.error = 'Test error'
})

it('should return the error message', () => {
expect(getError(mockState)).toBe('Test error')
})
})
})

describe('when checking if the campaign is loading', () => {
describe('and there is no fetch request in progress', () => {
it('should return false', () => {
expect(isLoading(mockState)).toBe(false)
})
})

describe('and there is a fetch request in progress', () => {
beforeEach(() => {
mockState.campaign.loading = [fetchCampaignRequest()]
})

it('should return true', () => {
expect(isLoading(mockState)).toBe(true)
})
})
})

describe('when getting the main tag', () => {
describe('and the data exists', () => {
it('should return the main tag', () => {
expect(getMainTag(mockState)).toBe('main')
})
})

describe('and there is no data', () => {
beforeEach(() => {
mockState.campaign.data = null
})

it('should return undefined', () => {
expect(getMainTag(mockState)).toBeUndefined()
})
})
})

describe('when getting the campaign name', () => {
describe('and the data exists', () => {
it('should return the campaign name', () => {
expect(getCampaignName(mockState)).toEqual({
'en-US': 'Test Campaign'
})
})
})

describe('and there is no data', () => {
beforeEach(() => {
mockState.campaign.data = null
})

it('should return null', () => {
expect(getCampaignName(mockState)).toBeNull()
})
})
})

describe('when getting additional tags', () => {
describe('and the data exists', () => {
it('should return the additional tags', () => {
expect(getAdditionalTags(mockState)).toEqual(['tag1', 'tag2'])
})
})

describe('and there is no data', () => {
beforeEach(() => {
mockState.campaign.data = null
})

it('should return an empty array', () => {
expect(getAdditionalTags(mockState)).toEqual([])
})
})
})

describe('when getting the contentful normalized locale', () => {
describe('and the locale is en', () => {
beforeEach(() => {
mockState.translation.locale = 'en'
})

it('should return en-US', () => {
expect(getContentfulNormalizedLocale(mockState)).toBe(
ContentfulLocale.enUS
)
})
})

describe('and the locale is es', () => {
beforeEach(() => {
mockState.translation.locale = 'es'
})

it('should return es', () => {
expect(getContentfulNormalizedLocale(mockState)).toBe(
ContentfulLocale.es
)
})
})

describe('and the locale is zh', () => {
beforeEach(() => {
mockState.translation.locale = 'zh'
})

it('should return zh', () => {
expect(getContentfulNormalizedLocale(mockState)).toBe(
ContentfulLocale.zh
)
})
})

describe('and the locale is not supported', () => {
beforeEach(() => {
mockState.translation.locale = 'fr'
})

it('should return en-US', () => {
expect(getContentfulNormalizedLocale(mockState)).toBe(
ContentfulLocale.enUS
)
})
})
})

describe('when getting all tags', () => {
describe('and the data exists', () => {
it('should return all tags including main tag', () => {
expect(getAllTags(mockState)).toEqual(['main', 'tag1', 'tag2'])
})
})

describe('and there is no data', () => {
beforeEach(() => {
mockState.campaign.data = null
})

it('should return an empty array', () => {
expect(getAllTags(mockState)).toEqual([])
})
})
})

describe('when getting assets', () => {
describe('and the data exists', () => {
it('should return the assets', () => {
expect(getAssets(mockState)).toEqual(mockState.campaign.data?.assets)
})
})

describe('and there is no data', () => {
beforeEach(() => {
mockState.campaign.data = null
})

it('should return null', () => {
expect(getAssets(mockState)).toBeNull()
})
})
})

describe('when getting the tab name', () => {
describe('and the data exists', () => {
it('should return the tab name', () => {
expect(getTabName(mockState)).toEqual({
'en-US': 'testTab'
})
})
})

describe('and there is no data', () => {
beforeEach(() => {
mockState.campaign.data = null
})

it('should return null', () => {
expect(getTabName(mockState)).toBeNull()
})
})
})

describe('when getting a banner', () => {
describe('and the banner exists', () => {
it('should return the banner', () => {
expect(getBanner(mockState, 'banner1')).toEqual(mockBanner)
})
})

describe('and the banner does not exist', () => {
it('should return null', () => {
expect(getBanner(mockState, 'nonexistent')).toBeNull()
})
})

describe('and there is no data', () => {
beforeEach(() => {
mockState.campaign.data = null
})

it('should return null', () => {
expect(getBanner(mockState, 'banner1')).toBeNull()
})
})
})

describe('when getting banner assets', () => {
describe('and the banner exists with assets', () => {
it('should return the assets associated with the banner', () => {
expect(getBannerAssets(mockState, 'banner1')).toEqual({
asset1: mockAsset
})
})
})

describe('and the banner does not exist', () => {
it('should return an empty object', () => {
expect(getBannerAssets(mockState, 'nonexistent')).toEqual({})
})
})

describe('and there are no assets', () => {
beforeEach(() => {
mockState.campaign.data!.assets = {}
})

it('should return an empty object', () => {
expect(getBannerAssets(mockState, 'banner1')).toEqual({})
})
})
})
})
52 changes: 52 additions & 0 deletions src/modules/campaign/selectors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { BannerFields, ContentfulAsset, ContentfulLocale, LocalizedField, isSysLink } from '@dcl/schemas'
import { getLocale } from '../translation'
import { isLoadingType, type LoadingState } from '../loading'
import { CampaignState } from './types'
import { FETCH_CAMPAIGN_REQUEST } from './actions'

const isLocalizedField = (value: any): value is LocalizedField<any> => typeof value === 'object' && value !== null && 'en-US' in value

export const getState = (state: any): CampaignState => state.campaign
export const getData = (state: any): CampaignState['data'] | null => getState(state).data
export const getLoading = (state: any): LoadingState => getState(state).loading
export const getError = (state: any): string | null => getState(state).error
export const isLoading = (state: any): boolean => isLoadingType(getLoading(state), FETCH_CAMPAIGN_REQUEST)
export const getMainTag = (state: any): string | undefined => getData(state)?.mainTag
export const getCampaignName = (state: any): LocalizedField<string> | null => getData(state)?.name || null
export const getAdditionalTags = (state: any): string[] => getData(state)?.additionalTags ?? []
export const getAllTags = (state: any): string[] => {
const mainTag = getMainTag(state)
const additionalTags = getAdditionalTags(state)
return [mainTag, ...additionalTags].filter(Boolean) as string[]
}
export const getAssets = (state: any): Record<string, ContentfulAsset> | null => getData(state)?.assets || null
export const getTabName = (state: any): LocalizedField<string> | null => getData(state)?.tabName || null
export const getBanner = (state: any, id: string): BannerFields & { id: string } | null => {
return getData(state)?.banners[id] ?? null
}
export const getBannerAssets = (state: any, bannerId: string): Record<string, ContentfulAsset> => {
const assets = getAssets(state)
const banner = getBanner(state, bannerId)

if (!banner) return {}

return Object.entries(banner).reduce((acc, [_, value]) => {
if (isLocalizedField(value)) {
const usLocalizedValue = value['en-US']
if (isSysLink(usLocalizedValue)) {
const asset = assets?.[usLocalizedValue.sys.id]
if (asset) {
acc[usLocalizedValue.sys.id] = asset
}
}
}
return acc
}, {} as Record<string, ContentfulAsset>)
}
export const getContentfulNormalizedLocale = (state: any): ContentfulLocale => {
const storeLocale = getLocale(state)
if (!['en', 'zh', 'es'].includes(storeLocale)) {
return ContentfulLocale.enUS
}
return storeLocale === 'en' ? ContentfulLocale.enUS : (storeLocale as ContentfulLocale)
}
19 changes: 19 additions & 0 deletions src/modules/campaign/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { ContentfulAsset, BannerFields, LocalizedField } from '@dcl/schemas'
import { ActionType } from 'typesafe-actions'
import { LoadingState } from '../loading/reducer'
import * as actions from './actions'

export type CampaignState = {
data: {
name?: LocalizedField<string>
tabName?: LocalizedField<string>
mainTag?: string
additionalTags?: string[]
banners: Record<string, BannerFields & { id: string }>
assets: Record<string, ContentfulAsset>
} | null
loading: LoadingState
error: string | null
}

export type CampaignAction = ActionType<typeof actions>
122 changes: 122 additions & 0 deletions src/modules/features/selectors.spec.ts
Original file line number Diff line number Diff line change
@@ -3,6 +3,7 @@ import { getMockApplicationFeaturesRecord } from './actions.spec'
import {
getData,
getError,
getFeatureVariant,
getIsFeatureEnabled,
getLoading,
hasLoadedInitialFlags,
@@ -191,6 +192,127 @@ describe('when getting if a feature is enabled', () => {
})
})

describe('when getting a feature variant', () => {
let data: ReturnType<typeof getMockApplicationFeaturesRecord>
let state: StateWithFeatures

beforeEach(() => {
data = getMockApplicationFeaturesRecord()
state = {
features: {
data,
error: null,
hasLoadedInitialFlags: false,
loading: []
}
}
})

describe('when the variant is defined in the environment', () => {
beforeEach(() => {
process.env.REACT_APP_FF_VARIANT_DAPPS_TEST_FEATURE = 'test-variant'
})

afterEach(() => {
delete process.env.REACT_APP_FF_VARIANT_DAPPS_TEST_FEATURE
})

it('should return a local variant with the environment value', () => {
expect(getFeatureVariant(state, ApplicationName.DAPPS, 'test-feature')).toEqual({
name: 'Local variant',
enabled: true,
payload: {
type: 'string',
value: 'test-variant'
}
})
})
})

describe('when the variant is not defined in the environment', () => {
describe('and the application features are not loaded', () => {
beforeEach(() => {
state = {
features: {
data: {},
error: null,
hasLoadedInitialFlags: false,
loading: []
}
}
})

it('should return null', () => {
expect(getFeatureVariant(state, ApplicationName.DAPPS, 'test-feature')).toBeNull()
})
})

describe('and the application features are loaded', () => {
describe('and the variant exists', () => {
beforeEach(() => {
state = {
features: {
data: {
[ApplicationName.DAPPS]: {
name: ApplicationName.DAPPS,
flags: {},
variants: {
'dapps-test-feature': {
name: 'Remote variant',
enabled: true,
payload: {
type: 'string',
value: 'remote-variant'
}
}
}
}
},
error: null,
hasLoadedInitialFlags: true,
loading: []
}
}
})

it('should return the variant from the store', () => {
expect(getFeatureVariant(state, ApplicationName.DAPPS, 'test-feature')).toEqual({
name: 'Remote variant',
enabled: true,
payload: {
type: 'string',
value: 'remote-variant'
}
})
})
})

describe('and the variant does not exist', () => {
beforeEach(() => {
state = {
features: {
data: {
[ApplicationName.DAPPS]: {
name: ApplicationName.DAPPS,
flags: {},
variants: {}
}
},
error: null,
hasLoadedInitialFlags: true,
loading: []
}
}
})

it('should return null', () => {
expect(getFeatureVariant(state, ApplicationName.DAPPS, 'test-feature')).toBeNull()
})
})
})
})
})

describe('when getting if the feature flags were loaded at least once', () => {
let state: StateWithFeatures

42 changes: 41 additions & 1 deletion src/modules/features/selectors.ts
Original file line number Diff line number Diff line change
@@ -5,7 +5,8 @@ import { FeaturesState } from './reducer'
import {
ApplicationName,
ApplicationFeatures,
StateWithFeatures
StateWithFeatures,
Variant
} from './types'

export const getState = (state: StateWithFeatures): FeaturesState => {
@@ -73,6 +74,34 @@ export const isLoadingFeatureFlags = (state: StateWithFeatures) => {
return isLoadingType(getLoading(state), FETCH_APPLICATION_FEATURES_REQUEST)
}

export const getFeatureVariant = (state: StateWithFeatures, app: ApplicationName, feature: string): Variant | null => {
const variant = getVariantFromEnv(app, feature)

// Return the flag variant if it has been defined in the env.
// If flag variants are only defined in the env, there is no need to implement the features reducer.
if (variant !== null) {
// Build the variant object
return {
name: 'Local variant',
enabled: true,
payload: {
type: 'string',
value: variant
}
}
}

const appFeatures = getData(state)[app]

// The app might not be defined in the store because the flag variants might not have been fetched yet.
// We suggest using isLoadingFeatureFlags and hasLoadedInitialFlags to handle this first.
if (!appFeatures) {
return null
}

return appFeatures.variants[`${app}-${feature}`] || null
}

export const hasLoadedInitialFlags = (state: StateWithFeatures) => {
return getState(state).hasLoadedInitialFlags
}
@@ -87,3 +116,14 @@ const getFromEnv = (

return !value || value === '' ? null : value === '1' ? true : false
}

const getVariantFromEnv = (
application: ApplicationName,
flag: string
): string | null => {
const envify = (word: string) => word.toUpperCase().replace(/-/g, '_')
const key = `REACT_APP_FF_VARIANT_${envify(application)}_${envify(flag)}`
const value = process.env[key]

return !value || value === '' ? null : value
}
213 changes: 213 additions & 0 deletions src/modules/transaction/sagas.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
// Mock the network provider
jest.mock('../../lib/eth', () => ({
getNetworkProvider: () => ({
send: jest.fn(),
on: jest.fn(),
removeListener: jest.fn()
})
}))

// Mock the getFibonacciDelay function
jest.mock('./sagas', () => {
const actual = jest.requireActual('./sagas')
return {
...actual,
BACKOFF_DELAY_MULTIPLIER: 0.01,
getFibonacciDelay: function*(attempt: number) {
const fib = [1, 1]
for (let i = 2; i <= attempt + 1; i++) {
fib[i] = fib[i - 1] + fib[i - 2]
}
if (attempt <= 1) {
return 100
}
return 100 * fib[attempt]
}
}
})

import { expectSaga } from 'redux-saga-test-plan'
import * as matchers from 'redux-saga-test-plan/matchers'
import { call, delay, select } from 'redux-saga/effects'
import { TransactionStatus, Transaction } from './types'
import { ChainId } from '@dcl/schemas/dist/dapps/chain-id'
import {
getFibonacciDelay,
handleRegularTransactionRequest,
handleWatchRevertedTransaction
} from './sagas'
import { fetchTransactionSuccess, watchRevertedTransaction } from './actions'
import { getTransaction as getTransactionInState } from './selectors'
import { buildTransactionPayload } from './utils'
import { getTransaction as getTransactionFromChain } from './txUtils'

describe('when using fibonacci backoff for transaction polling', () => {
const MOCK_DELAY_MULTIPLIER = 100 // 100ms for testing
jest.setTimeout(20000) // Increase global timeout

describe('when calculating fibonacci delay', () => {
const cases = [
{ attempt: 0, expected: MOCK_DELAY_MULTIPLIER },
{ attempt: 1, expected: MOCK_DELAY_MULTIPLIER },
{ attempt: 2, expected: MOCK_DELAY_MULTIPLIER * 2 },
{ attempt: 3, expected: MOCK_DELAY_MULTIPLIER * 3 },
{ attempt: 4, expected: MOCK_DELAY_MULTIPLIER * 5 },
{ attempt: 5, expected: MOCK_DELAY_MULTIPLIER * 8 },
{ attempt: 6, expected: MOCK_DELAY_MULTIPLIER * 13 }
]

cases.forEach(({ attempt, expected }) => {
it(`should return ${expected}ms for attempt ${attempt}`, () => {
return expectSaga(getFibonacciDelay, attempt)
.returns(expected)
.run()
})
})
})

describe('when polling regular transaction status', () => {
let transaction: Transaction
let address: string
let hash: string
let chainId: ChainId

beforeEach(() => {
jest.setTimeout(20000) // Set timeout for each test in this suite
address = '0x123'
hash = '0x456'
chainId = ChainId.ETHEREUM_MAINNET
transaction = {
...buildTransactionPayload(chainId, hash, {}, chainId),
events: [],
hash,
from: address,
chainId,
status: null,
timestamp: Date.now(),
nonce: 0,
withReceipt: false,
isCrossChain: false,
actionType: 'SOME_ACTION',
url: '',
replacedBy: null
}
})

describe('and the transaction becomes confirmed', () => {
it('should use fibonacci backoff until confirmation and fix the transaction', async () => {
const { hash } = transaction
const mockReceipt = { logs: [] }
const revertedTx = {
...transaction,
status: TransactionStatus.REVERTED
}
const action = {
type: '[Request] Fetch Transaction' as const,
payload: {
hash,
address: '0x123',
action: {
type: 'SOME_ACTION',
payload: buildTransactionPayload(
transaction.chainId,
hash,
{},
transaction.chainId
)
}
}
}

return expectSaga(handleRegularTransactionRequest, action)
.provide([
[select(getTransactionInState, hash), revertedTx],
[
call(getTransactionFromChain, '0x123', transaction.chainId, hash),
{
...revertedTx,
status: TransactionStatus.CONFIRMED,
receipt: mockReceipt
}
]
])
.withState({
transaction: {
data: [revertedTx],
loading: [],
error: null
},
wallet: {
data: {
address: '0x123'
}
}
})
.put(
fetchTransactionSuccess({
...revertedTx,
status: TransactionStatus.CONFIRMED,
receipt: { logs: [] }
})
)
.run({ timeout: 15000 })
})
})
})

describe('when watching reverted transaction', () => {
let transaction: Transaction
let address: string
let hash: string
let chainId: ChainId

beforeEach(() => {
address = '0x123'
hash = '0x456'
chainId = ChainId.ETHEREUM_MAINNET
transaction = {
...buildTransactionPayload(chainId, hash, {}, chainId),
events: [],
hash,
from: address,
chainId,
status: TransactionStatus.REVERTED,
timestamp: Date.now(),
nonce: 0,
withReceipt: false,
isCrossChain: false,
replacedBy: null,
actionType: 'SOME_ACTION',
url: ''
}
})

describe('and the transaction expires', () => {
it('should stop polling after expiration threshold', () => {
const expiredTransaction = {
...transaction,
timestamp: Date.now() - 25 * 60 * 60 * 1000 // 25 hours ago
}

return expectSaga(
handleWatchRevertedTransaction,
watchRevertedTransaction(hash)
)
.provide([
[matchers.select(getTransactionInState, hash), expiredTransaction]
])
.withState({
transaction: {
data: [expiredTransaction]
},
wallet: {
data: {
address
}
}
})
.not.call(getTransactionFromChain)
.run()
})
})
})
})
83 changes: 63 additions & 20 deletions src/modules/transaction/sagas.ts
Original file line number Diff line number Diff line change
@@ -79,11 +79,12 @@ export function* transactionSaga(
}
}

const BLOCKS_DEPTH = 100
const TRANSACTION_FETCH_RETIES = 120
const PENDING_TRANSACTION_THRESHOLD = 72 * 60 * 60 * 1000 // 72 hours
const REVERTED_TRANSACTION_THRESHOLD = 24 * 60 * 60 * 1000 // 24 hours
const TRANSACTION_FETCH_DELAY = 2 * 1000 // 2 seconds
export const BLOCKS_DEPTH = 100
export const TRANSACTION_FETCH_RETIES = 120
export const PENDING_TRANSACTION_THRESHOLD = 72 * 60 * 60 * 1000 // 72 hours
export const REVERTED_TRANSACTION_THRESHOLD = 24 * 60 * 60 * 1000 // 24 hours
export const DROPPED_TRANSACTION_THRESHOLD = 24 * 60 * 60 * 1000 // 24 hours
export const BACKOFF_DELAY_MULTIPLIER = 1

const isExpired = (transaction: Transaction, threshold: number) =>
Date.now() - transaction.timestamp > threshold
@@ -107,7 +108,7 @@ export class FailedTransactionError extends Error {
}
}

function* handleCrossChainTransactionRequest(
export function* handleCrossChainTransactionRequest(
action: FetchTransactionRequestAction,
config?: TransactionsConfig
) {
@@ -128,6 +129,7 @@ function* handleCrossChainTransactionRequest(

let statusResponse: StatusResponse | undefined
let txInState: Transaction
let attempt = 0
let squidNotFoundRetries: number =
config.crossChainProviderNotFoundRetries ?? TRANSACTION_FETCH_RETIES
while (
@@ -175,7 +177,10 @@ function* handleCrossChainTransactionRequest(
)
return
}
yield delay(config.crossChainProviderRetryDelay ?? TRANSACTION_FETCH_DELAY)

const fibonacciDelay: number = yield call(getFibonacciDelay, attempt)
yield delay(config.crossChainProviderRetryDelay ?? fibonacciDelay)
attempt++
}

txInState = yield select(state =>
@@ -208,7 +213,7 @@ function* handleCrossChainTransactionRequest(
}
}

function* handleRegularTransactionRequest(
export function* handleRegularTransactionRequest(
action: FetchTransactionRequestAction
) {
const { hash, address } = action.payload
@@ -222,6 +227,7 @@ function* handleRegularTransactionRequest(

try {
watchPendingIndex[hash] = true
let attempt = 0

let tx: AnyTransaction = yield call(
getTransactionFromChain,
@@ -266,8 +272,10 @@ function* handleRegularTransactionRequest(
yield put(updateTransactionStatus(hash, statusInNetwork))
}

// sleep
yield delay(TRANSACTION_FETCH_DELAY)
// Apply fibonacci backoff delay before next iteration
const fibonacciDelay: number = yield call(getFibonacciDelay, attempt)
yield delay(fibonacciDelay)
attempt++

// update tx status from network
tx = yield call(
@@ -309,7 +317,20 @@ function* handleRegularTransactionRequest(
}
}

function* handleReplaceTransactionRequest(
export function* getFibonacciDelay(attempt: number) {
if (attempt <= 1) return 1000 * BACKOFF_DELAY_MULTIPLIER

let prev = 1
let current = 1
for (let i = 2; i <= attempt; i++) {
const next = prev + current
prev = current
current = next
}
return current * 1000 * BACKOFF_DELAY_MULTIPLIER
}

export function* handleReplaceTransactionRequest(
action: ReplaceTransactionRequestAction
) {
const { hash, nonce, address: account } = action.payload
@@ -321,10 +342,25 @@ function* handleReplaceTransactionRequest(
return
}

// Check if transaction is already expired before starting to poll
if (isExpired(transaction, DROPPED_TRANSACTION_THRESHOLD)) {
yield put(updateTransactionStatus(hash, TransactionStatus.DROPPED))
return
}

let checkpoint = null
let attempt = 0
watchDroppedIndex[hash] = true

const startTime = Date.now()

while (true) {
// Check if we've exceeded the time threshold during polling
if (Date.now() - startTime > DROPPED_TRANSACTION_THRESHOLD) {
yield put(updateTransactionStatus(hash, TransactionStatus.DROPPED))
break
}

const eth: ethers.providers.Web3Provider = yield call(
getNetworkWeb3Provider,
transaction.chainId
@@ -398,22 +434,24 @@ function* handleReplaceTransactionRequest(
break
}

// if there was nonce higher to than the one in the tx, we can mark it as replaced (altough we don't know which tx replaced it)
// if there was nonce higher to than the one in the tx, we can mark it as replaced (although we don't know which tx replaced it)
if (highestNonce >= nonce) {
yield put(
updateTransactionStatus(action.payload.hash, TransactionStatus.REPLACED)
)
break
}

// sleep
yield delay(TRANSACTION_FETCH_DELAY)
// Apply fibonacci backoff delay before next iteration
const fibonacciDelay: number = yield call(getFibonacciDelay, attempt)
yield delay(fibonacciDelay)
attempt++
}

delete watchDroppedIndex[action.payload.hash]
}

function* handleWatchPendingTransactions() {
export function* handleWatchPendingTransactions() {
const transactions: Transaction[] = yield select(getData)
const pendingTransactions = transactions.filter(transaction =>
isPending(transaction.status)
@@ -435,12 +473,13 @@ function* handleWatchPendingTransactions() {
}
}

function* handleWatchDroppedTransactions() {
export function* handleWatchDroppedTransactions() {
const transactions: Transaction[] = yield select(getData)
const droppedTransactions = transactions.filter(
transaction =>
transaction.status === TransactionStatus.DROPPED &&
transaction.nonce != null
transaction.nonce != null &&
!isExpired(transaction, DROPPED_TRANSACTION_THRESHOLD)
)

for (const tx of droppedTransactions) {
@@ -453,7 +492,7 @@ function* handleWatchDroppedTransactions() {
}
}

function* handleWatchRevertedTransaction(
export function* handleWatchRevertedTransaction(
action: WatchRevertedTransactionAction
) {
const { hash } = action.payload
@@ -468,9 +507,13 @@ function* handleWatchRevertedTransaction(
}

const address: string = yield select(state => getAddress(state))
let attempt = 0

do {
yield delay(TRANSACTION_FETCH_DELAY)
const fibonacciDelay: number = yield call(getFibonacciDelay, attempt)
yield delay(fibonacciDelay)
attempt++

const txInNetwork: AnyTransaction | null = yield call(() =>
getTransactionFromChain(address, txInState.chainId, hash)
)
@@ -489,7 +532,7 @@ function* handleWatchRevertedTransaction(
} while (!isExpired(txInState, REVERTED_TRANSACTION_THRESHOLD))
}

function* handleConnectWalletSuccess(_: ConnectWalletSuccessAction) {
export function* handleConnectWalletSuccess(_: ConnectWalletSuccessAction) {
yield put(watchPendingTransactions())
yield put(watchDroppedTransactions())

592 changes: 592 additions & 0 deletions src/tests/contentfulMocks.ts

Large diffs are not rendered by default.