Skip to content

Commit

Permalink
feat: Create new linked wearable collection (#3134)
Browse files Browse the repository at this point in the history
* feat: Create new linked wearable collection

* feat: Add features module

* fix: Remove commented lines

* fix: Validate the collection name when creating it
  • Loading branch information
LautaroPetaccio authored Jul 3, 2024
1 parent 4169316 commit 574f08c
Show file tree
Hide file tree
Showing 36 changed files with 948 additions and 52 deletions.
3 changes: 2 additions & 1 deletion src/components/CollectionsPage/CollectionsPage.container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { setCollectionPageView } from 'modules/ui/collection/actions'
import { getCollectionPageView } from 'modules/ui/collection/selectors'
import { isThirdPartyManager } from 'modules/thirdParty/selectors'
import { fetchItemsRequest, fetchOrphanItemRequest, FETCH_ITEMS_REQUEST, FETCH_ORPHAN_ITEM_REQUEST } from 'modules/item/actions'
import { getIsCampaignEnabled } from 'modules/features/selectors'
import { getIsCampaignEnabled, getIsLinkedWearablesV2Enabled } from 'modules/features/selectors'
import { fetchCollectionsRequest, FETCH_COLLECTIONS_REQUEST } from 'modules/collection/actions'
import { MapStateProps, MapDispatchProps, MapDispatch } from './CollectionsPage.types'
import CollectionsPage from './CollectionsPage'
Expand All @@ -37,6 +37,7 @@ const mapState = (state: RootState): MapStateProps => {
isLoadingCollections: isLoadingType(getLoadingCollections(state), FETCH_COLLECTIONS_REQUEST),
isLoadingItems: isLoadingType(getLoadingItems(state), FETCH_ITEMS_REQUEST),
isLoadingOrphanItem: isLoadingType(getLoadingItems(state), FETCH_ORPHAN_ITEM_REQUEST),
isLinkedWearablesV2Enabled: getIsLinkedWearablesV2Enabled(state),
isCampaignEnabled: getIsCampaignEnabled(state),
hasUserOrphanItems: hasUserOrphanItems(state)
}
Expand Down
7 changes: 6 additions & 1 deletion src/components/CollectionsPage/CollectionsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export default function CollectionsPage(props: Props) {
isLoadingItems,
isLoadingCollections,
isLoadingOrphanItem,
isLinkedWearablesV2Enabled,
isThirdPartyManager,
onFetchCollections,
onFetchOrphanItem,
Expand Down Expand Up @@ -79,7 +80,11 @@ export default function CollectionsPage(props: Props) {
}, [onOpenModal])

const handleNewThirdPartyCollection = useCallback(() => {
onOpenModal('CreateThirdPartyCollectionModal')
if (isLinkedWearablesV2Enabled) {
onOpenModal('CreateLinkedWearablesCollectionModal')
} else {
onOpenModal('CreateThirdPartyCollectionModal')
}
}, [onOpenModal])

const handleSearchChange = (_evt: React.ChangeEvent<HTMLInputElement>, data: InputOnChangeData) => {
Expand Down
2 changes: 2 additions & 0 deletions src/components/CollectionsPage/CollectionsPage.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export type Props = {
items: Item[]
collections: Collection[]
collectionsPaginationData: CollectionPaginationData | null
isLinkedWearablesV2Enabled: boolean
itemsPaginationData?: ItemPaginationData | null
view: CollectionPageView
isThirdPartyManager: boolean
Expand Down Expand Up @@ -48,6 +49,7 @@ export type MapStateProps = Pick<
| 'isLoadingOrphanItem'
| 'isCampaignEnabled'
| 'hasUserOrphanItems'
| 'isLinkedWearablesV2Enabled'
>
export type MapDispatchProps = Pick<Props, 'onSetView' | 'onOpenModal' | 'onFetchOrphanItems' | 'onFetchCollections' | 'onFetchOrphanItem'>
export type MapDispatch = Dispatch<
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { connect } from 'react-redux'
import { getAddress } from 'decentraland-dapps/dist/modules/wallet/selectors'
import { isLoadingType } from 'decentraland-dapps/dist/modules/loading/selectors'
import { RootState } from 'modules/common/types'
import { getLoading } from 'modules/collection/selectors'
import { SAVE_COLLECTION_REQUEST, saveCollectionRequest } from 'modules/collection/actions'
import { getWalletThirdParties, getError } from 'modules/thirdParty/selectors'
import { MapStateProps, MapDispatchProps, MapDispatch } from './CreateLinkedWearablesCollectionModal.types'
import { CreateLinkedWearablesCollectionModal } from './CreateLinkedWearablesCollectionModal'

const mapState = (state: RootState): MapStateProps => ({
ownerAddress: getAddress(state),
thirdParties: getWalletThirdParties(state),
error: getError(state),
isCreatingCollection: isLoadingType(getLoading(state), SAVE_COLLECTION_REQUEST)
})

const mapDispatch = (dispatch: MapDispatch): MapDispatchProps => ({
onSubmit: collection => dispatch(saveCollectionRequest(collection))
})

export default connect(mapState, mapDispatch)(CreateLinkedWearablesCollectionModal)
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
.linkedContractSelect :global(.divider.text),
.linkedContractSelect :global(.visible.menu.transition) > div {
display: flex !important;
align-items: center !important;
}

.linkedContractSelect :global(.divider.text) img,
.linkedContractSelect :global(.visible.menu.transition) img {
height: 25px;
width: 25px;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import { useState, useMemo, useCallback, FC, SyntheticEvent } from 'react'
import slug from 'slug'
import { Collection, TP_COLLECTION_NAME_MAX_LENGTH } from 'modules/collection/types'
import {
ModalNavigation,
Button,
Form,
Field,
ModalContent,
ModalActions,
SelectField,
InputOnChangeData,
DropdownProps
} from 'decentraland-ui'
import uuid from 'uuid'
import { t } from 'decentraland-dapps/dist/modules/translation/utils'
import Modal from 'decentraland-dapps/dist/containers/Modal'
import { getAnalytics } from 'decentraland-dapps/dist/modules/analytics'
import { LinkedContract, ThirdPartyVersion } from 'modules/thirdParty/types'
import { LinkedContractProtocol, buildThirdPartyURN, buildThirdPartyV2URN, decodeURN } from 'lib/urn'
import { getThirdPartyVersion } from 'modules/thirdParty/utils'
import ethereumSvg from '../../../icons/ethereum.svg'
import polygonSvg from '../../../icons/polygon.svg'
import { Props } from './CreateLinkedWearablesCollectionModal.types'
import styles from './CreateLinkedWearablesCollectionModal.module.css'

const imgSrcByNetwork = {
[LinkedContractProtocol.MAINNET]: ethereumSvg,
[LinkedContractProtocol.MATIC]: polygonSvg,
[LinkedContractProtocol.SEPOLIA]: ethereumSvg,
[LinkedContractProtocol.AMOY]: polygonSvg
}

export const CreateLinkedWearablesCollectionModal: FC<Props> = (props: Props) => {
const { name, thirdParties, onClose, isCreatingCollection, error, ownerAddress, onSubmit } = props
const [collectionName, setCollectionName] = useState('')
const [linkedContract, setLinkedContract] = useState<LinkedContract>()
const [hasCollectionIdBeenTyped, setHasCollectionIdBeenTyped] = useState(false)
const [collectionId, setCollectionId] = useState('')
const [thirdPartyId, setThirdPartyId] = useState(thirdParties[0].id)
const analytics = getAnalytics()

const selectedThirdParty = useMemo(() => {
return thirdParties.find(thirdParty => thirdParty.id === thirdPartyId) || thirdParties[0]
}, [thirdParties, thirdPartyId])
const selectedThirdPartyVersion = useMemo(
() => (selectedThirdParty ? getThirdPartyVersion(selectedThirdParty) : undefined),
[selectedThirdParty, getThirdPartyVersion]
)
const thirdPartyOptions = useMemo(() => thirdParties.map(thirdParty => ({ value: thirdParty.id, text: thirdParty.name })), [thirdParties])
const linkedContractsOptions = useMemo(
() =>
selectedThirdParty?.contracts.map((contract, index) => ({
value: index,
key: index,
image: imgSrcByNetwork[contract.network],
text: contract.address
})),
[selectedThirdParty, imgSrcByNetwork]
)
const isCollectionNameInvalid = useMemo(() => collectionName.includes(':'), [collectionName])

const handleNameChange = useCallback(
(_: SyntheticEvent, data: InputOnChangeData) => {
setCollectionName(data.value)
setCollectionId(hasCollectionIdBeenTyped ? collectionId : slug(data.value))
},
[setCollectionName, hasCollectionIdBeenTyped, collectionId]
)
const handleThirdPartyChange = useCallback(
(_: React.SyntheticEvent, data: DropdownProps) => {
if (data.value) {
setLinkedContract(undefined)
setThirdPartyId(data.value.toString())
}
},
[setThirdPartyId, setLinkedContract]
)
const handleLinkedContractChange = useCallback(
(_: SyntheticEvent<HTMLElement, Event>, data: DropdownProps) => {
setLinkedContract(selectedThirdParty.contracts[data.value as number])
},
[selectedThirdParty, setLinkedContract]
)
const handleCollectionIdChange = useCallback(
(_event: React.ChangeEvent<HTMLInputElement>, data: InputOnChangeData) => {
setCollectionId(data.value)
setHasCollectionIdBeenTyped(!!data.value)
},
[setCollectionId, setHasCollectionIdBeenTyped]
)

const handleSubmit = useCallback(() => {
if (
collectionName &&
ownerAddress &&
((selectedThirdPartyVersion === ThirdPartyVersion.V2 && linkedContract) ||
(selectedThirdPartyVersion === ThirdPartyVersion.V1 && collectionId))
) {
const now = Date.now()
const decodedURN = decodeURN(selectedThirdParty.id)
const urn =
selectedThirdPartyVersion === ThirdPartyVersion.V1
? buildThirdPartyURN(decodedURN.suffix, collectionId)
: buildThirdPartyV2URN(decodedURN.suffix, linkedContract!.network, linkedContract!.address)
const collection: Collection = {
id: uuid.v4(),
name: collectionName,
owner: ownerAddress,
urn,
isPublished: false,
isApproved: false,
minters: [],
managers: [],
createdAt: now,
updatedAt: now
}
onSubmit(collection)
analytics.track('Create TP Collection', {
collectionId: collection.id,
version: selectedThirdPartyVersion,
thirdPartyId: selectedThirdParty.id,
linkedContract: linkedContract?.address,
linkedContractNetwork: linkedContract?.network,
collectionName
})
}
}, [onSubmit, collectionId, collectionName, selectedThirdPartyVersion, selectedThirdParty, linkedContract, ownerAddress, analytics])

const isSubmittable =
collectionName &&
ownerAddress &&
!isCollectionNameInvalid &&
((selectedThirdPartyVersion === ThirdPartyVersion.V2 && linkedContract) ||
(selectedThirdPartyVersion === ThirdPartyVersion.V1 && collectionId)) &&
!isCreatingCollection
const isLoading = isCreatingCollection

return (
<Modal name={name} onClose={onClose} size="small">
<ModalNavigation
title={t('create_linked_wearable_collection_modal.title')}
subtitle={t('create_linked_wearable_collection_modal.subtitle')}
onClose={onClose}
/>
<Form onSubmit={handleSubmit} disabled={!isSubmittable}>
<ModalContent>
<SelectField
label={t('create_linked_wearable_collection_modal.third_party_field.label')}
options={thirdPartyOptions}
onChange={handleThirdPartyChange}
disabled={isLoading}
value={selectedThirdParty.id}
/>
{selectedThirdPartyVersion === ThirdPartyVersion.V2 && (
<SelectField
label={t('create_linked_wearable_collection_modal.linked_contract_field.label')}
className={styles.linkedContractSelect}
disabled={linkedContractsOptions.length === 0}
value={linkedContract ? selectedThirdParty.contracts.indexOf(linkedContract) : undefined}
options={linkedContractsOptions}
search={false}
onChange={handleLinkedContractChange}
message={
linkedContractsOptions.length === 0 ? t('create_linked_wearable_collection_modal.linked_contract_field.message') : ''
}
/>
)}
<Field
label={t('create_linked_wearable_collection_modal.name_field.label')}
placeholder="aName"
value={collectionName}
maxLength={TP_COLLECTION_NAME_MAX_LENGTH}
onChange={handleNameChange}
error={isCollectionNameInvalid}
message={isCollectionNameInvalid ? t('create_linked_wearable_collection_modal.name_field.message') : ''}
disabled={isLoading}
/>
{selectedThirdPartyVersion === ThirdPartyVersion.V1 && (
<Field
label={t('create_linked_wearable_collection_modal.collection_id_field.label')}
placeholder="0x..."
message={t('create_linked_wearable_collection_modal.collection_id_field.message')}
value={collectionId}
onChange={handleCollectionIdChange}
/>
)}
{error ? <small className="danger-text">{error}</small> : null}
</ModalContent>
<ModalActions>
<Button primary disabled={!isSubmittable} loading={isLoading}>
{t('global.create')}
</Button>
</ModalActions>
</Form>
</Modal>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Dispatch } from 'redux'
import { ModalProps } from 'decentraland-dapps/dist/providers/ModalProvider/ModalProvider.types'
import { SaveCollectionRequestAction, saveCollectionRequest } from 'modules/collection/actions'
import { ThirdParty } from 'modules/thirdParty/types'

export type Props = ModalProps & {
ownerAddress?: string
thirdParties: ThirdParty[]
isCreatingCollection: boolean
error: string | null
onSubmit: typeof saveCollectionRequest
}

export type State = {
thirdPartyId: string
collectionName: string
urnSuffix: string
isTypedUrnSuffix: boolean
}

export type MapStateProps = Pick<Props, 'ownerAddress' | 'thirdParties' | 'error' | 'isCreatingCollection'>
export type MapDispatchProps = Pick<Props, 'onSubmit'>
export type MapDispatch = Dispatch<SaveCollectionRequestAction>
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './CreateLinkedWearablesCollectionModal.types'
import CreateLinkedWearablesCollectionModal from './CrateLinkedWearablesCollectionModal.container'
export default CreateLinkedWearablesCollectionModal
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { connect } from 'react-redux'
import { RootState } from 'modules/common/types'
import { isLoadingType } from 'decentraland-dapps/dist/modules/loading/selectors'
import { buildThirdPartyURN, DecodedURN, URNType } from 'lib/urn'
import { buildThirdPartyURN, DecodedURN, isThirdPartyCollectionDecodedUrn, URNType } from 'lib/urn'
import { getLoading as getCollectionLoading, getError } from 'modules/collection/selectors'
import { saveCollectionRequest, SAVE_COLLECTION_REQUEST } from 'modules/collection/actions'
import { MapStateProps, MapDispatchProps, MapDispatch, OwnProps } from './EditCollectionURNModal.types'
Expand All @@ -19,8 +19,10 @@ const mapState = (state: RootState, ownProps: OwnProps): MapStateProps => {

const mapDispatch = (dispatch: MapDispatch, ownProps: OwnProps): MapDispatchProps => ({
onSave: urn => dispatch(saveCollectionRequest({ ...ownProps.metadata.collection, urn })),
onBuildURN: (decodedURN: DecodedURN<URNType.COLLECTIONS_THIRDPARTY>, collectionId: string) =>
buildThirdPartyURN(decodedURN.thirdPartyName, collectionId)
onBuildURN: (
decodedURN: DecodedURN<URNType.COLLECTIONS_THIRDPARTY> | DecodedURN<URNType.COLLECTIONS_THIRDPARTY_V2>,
collectionId: string
) => (isThirdPartyCollectionDecodedUrn(decodedURN) ? buildThirdPartyURN(decodedURN.thirdPartyName, collectionId) : '')
})

export default connect(mapState, mapDispatch)(EditURNModal)
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ export type Props = ModalProps & {
metadata: EditURNModalMetadata
error: string | null
onSave: (urn: string) => ReturnType<typeof saveCollectionRequest>
onBuildURN: (decodedURN: DecodedURN<URNType.COLLECTIONS_THIRDPARTY>, collectionId: string) => string
onBuildURN: (
decodedURN: DecodedURN<URNType.COLLECTIONS_THIRDPARTY> | DecodedURN<URNType.COLLECTIONS_THIRDPARTY_V2>,
collectionId: string
) => string
}

export type EditURNModalMetadata = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@ const mapState = (state: RootState, ownProps: OwnProps): MapStateProps => {

const mapDispatch = (dispatch: MapDispatch, ownProps: OwnProps): MapDispatchProps => ({
onSave: (urn: string) => dispatch(saveItemRequest({ ...ownProps.metadata.item, urn }, {})),
onBuildURN: (decodedURN: DecodedURN<URNType.COLLECTIONS_THIRDPARTY>, tokenId: string) =>
buildThirdPartyURN(decodedURN.thirdPartyName, decodedURN.thirdPartyCollectionId!, tokenId)
onBuildURN: (decodedURN: DecodedURN<URNType.COLLECTIONS_THIRDPARTY> | DecodedURN<URNType.COLLECTIONS_THIRDPARTY_V2>, tokenId: string) =>
decodedURN.type === URNType.COLLECTIONS_THIRDPARTY
? buildThirdPartyURN(decodedURN.thirdPartyName, decodedURN.thirdPartyCollectionId!, tokenId)
: buildThirdPartyURN(decodedURN.thirdPartyLinkedCollectionName, decodedURN.linkedCollectionContractAddress, tokenId)
})

export default connect(mapState, mapDispatch)(EditURNModal)
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ export type Props = ModalProps & {
metadata: EditURNModalMetadata
error: string | null
onSave: (urn: string) => ReturnType<typeof saveItemRequest>
onBuildURN: (decodedURN: DecodedURN<URNType.COLLECTIONS_THIRDPARTY>, tokenId: string) => string
onBuildURN: (
decodedURN: DecodedURN<URNType.COLLECTIONS_THIRDPARTY> | DecodedURN<URNType.COLLECTIONS_THIRDPARTY_V2>,
tokenId: string
) => string
}

export type EditURNModalMetadata = {
Expand Down
Loading

0 comments on commit 574f08c

Please sign in to comment.