From 574f08cf53b78e5f63234f88dc7543a16ca1798d Mon Sep 17 00:00:00 2001 From: Lautaro Petaccio <1120791+LautaroPetaccio@users.noreply.github.com> Date: Wed, 3 Jul 2024 17:57:18 -0300 Subject: [PATCH] feat: Create new linked wearable collection (#3134) * feat: Create new linked wearable collection * feat: Add features module * fix: Remove commented lines * fix: Validate the collection name when creating it --- .../CollectionsPage.container.ts | 3 +- .../CollectionsPage/CollectionsPage.tsx | 7 +- .../CollectionsPage/CollectionsPage.types.ts | 2 + ...inkedWearablesCollectionModal.container.ts | 22 ++ ...eLinkedWearablesCollectionModal.module.css | 11 + .../CreateLinkedWearablesCollectionModal.tsx | 198 +++++++++++++ ...ateLinkedWearablesCollectionModal.types.ts | 23 ++ .../index.ts | 3 + .../EditCollectionURNModal.container.ts | 8 +- .../EditCollectionURNModal.types.ts | 5 +- .../EditItemURNModal.container.ts | 6 +- .../EditItemURNModal.types.ts | 5 +- .../EditURNModal/EditURNModal.tsx | 10 +- .../EditURNModal/EditURNModal.types.ts | 5 +- src/components/Modals/index.ts | 1 + src/icons/polygon.svg | 79 ++++++ src/lib/urn.spec.ts | 268 +++++++++++++++++- src/lib/urn.ts | 118 +++++++- src/modules/collection/sagas.spec.ts | 14 +- src/modules/collection/sagas.ts | 15 +- src/modules/collection/selectors.spec.ts | 1 + src/modules/collection/utils.ts | 1 + .../curations/itemCuration/reducer.spec.ts | 5 + src/modules/features/selectors.spec.ts | 4 +- src/modules/features/selectors.ts | 8 + src/modules/features/types.ts | 3 +- src/modules/item/utils.spec.ts | 79 +++++- src/modules/item/utils.ts | 10 +- src/modules/thirdParty/reducer.spec.ts | 1 + src/modules/thirdParty/sagas.spec.ts | 6 +- src/modules/thirdParty/selectors.spec.ts | 3 + src/modules/thirdParty/types.ts | 13 + src/modules/thirdParty/utils.ts | 5 +- src/modules/translation/languages/en.json | 19 ++ src/modules/translation/languages/es.json | 19 ++ src/modules/translation/languages/zh.json | 20 +- 36 files changed, 948 insertions(+), 52 deletions(-) create mode 100644 src/components/Modals/CreateLinkedWearablesCollectionModal/CrateLinkedWearablesCollectionModal.container.ts create mode 100644 src/components/Modals/CreateLinkedWearablesCollectionModal/CreateLinkedWearablesCollectionModal.module.css create mode 100644 src/components/Modals/CreateLinkedWearablesCollectionModal/CreateLinkedWearablesCollectionModal.tsx create mode 100644 src/components/Modals/CreateLinkedWearablesCollectionModal/CreateLinkedWearablesCollectionModal.types.ts create mode 100644 src/components/Modals/CreateLinkedWearablesCollectionModal/index.ts create mode 100644 src/icons/polygon.svg diff --git a/src/components/CollectionsPage/CollectionsPage.container.ts b/src/components/CollectionsPage/CollectionsPage.container.ts index c7fa9aa39..9abb26e34 100644 --- a/src/components/CollectionsPage/CollectionsPage.container.ts +++ b/src/components/CollectionsPage/CollectionsPage.container.ts @@ -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' @@ -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) } diff --git a/src/components/CollectionsPage/CollectionsPage.tsx b/src/components/CollectionsPage/CollectionsPage.tsx index 2e93bf7a0..8df4b97d8 100644 --- a/src/components/CollectionsPage/CollectionsPage.tsx +++ b/src/components/CollectionsPage/CollectionsPage.tsx @@ -50,6 +50,7 @@ export default function CollectionsPage(props: Props) { isLoadingItems, isLoadingCollections, isLoadingOrphanItem, + isLinkedWearablesV2Enabled, isThirdPartyManager, onFetchCollections, onFetchOrphanItem, @@ -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, data: InputOnChangeData) => { diff --git a/src/components/CollectionsPage/CollectionsPage.types.ts b/src/components/CollectionsPage/CollectionsPage.types.ts index c422e60c8..320fc9056 100644 --- a/src/components/CollectionsPage/CollectionsPage.types.ts +++ b/src/components/CollectionsPage/CollectionsPage.types.ts @@ -19,6 +19,7 @@ export type Props = { items: Item[] collections: Collection[] collectionsPaginationData: CollectionPaginationData | null + isLinkedWearablesV2Enabled: boolean itemsPaginationData?: ItemPaginationData | null view: CollectionPageView isThirdPartyManager: boolean @@ -48,6 +49,7 @@ export type MapStateProps = Pick< | 'isLoadingOrphanItem' | 'isCampaignEnabled' | 'hasUserOrphanItems' + | 'isLinkedWearablesV2Enabled' > export type MapDispatchProps = Pick export type MapDispatch = Dispatch< diff --git a/src/components/Modals/CreateLinkedWearablesCollectionModal/CrateLinkedWearablesCollectionModal.container.ts b/src/components/Modals/CreateLinkedWearablesCollectionModal/CrateLinkedWearablesCollectionModal.container.ts new file mode 100644 index 000000000..8f1e3ce01 --- /dev/null +++ b/src/components/Modals/CreateLinkedWearablesCollectionModal/CrateLinkedWearablesCollectionModal.container.ts @@ -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) diff --git a/src/components/Modals/CreateLinkedWearablesCollectionModal/CreateLinkedWearablesCollectionModal.module.css b/src/components/Modals/CreateLinkedWearablesCollectionModal/CreateLinkedWearablesCollectionModal.module.css new file mode 100644 index 000000000..e8883bd5e --- /dev/null +++ b/src/components/Modals/CreateLinkedWearablesCollectionModal/CreateLinkedWearablesCollectionModal.module.css @@ -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; +} diff --git a/src/components/Modals/CreateLinkedWearablesCollectionModal/CreateLinkedWearablesCollectionModal.tsx b/src/components/Modals/CreateLinkedWearablesCollectionModal/CreateLinkedWearablesCollectionModal.tsx new file mode 100644 index 000000000..d54539303 --- /dev/null +++ b/src/components/Modals/CreateLinkedWearablesCollectionModal/CreateLinkedWearablesCollectionModal.tsx @@ -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) => { + const { name, thirdParties, onClose, isCreatingCollection, error, ownerAddress, onSubmit } = props + const [collectionName, setCollectionName] = useState('') + const [linkedContract, setLinkedContract] = useState() + 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, data: DropdownProps) => { + setLinkedContract(selectedThirdParty.contracts[data.value as number]) + }, + [selectedThirdParty, setLinkedContract] + ) + const handleCollectionIdChange = useCallback( + (_event: React.ChangeEvent, 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 ( + + +
+ + + {selectedThirdPartyVersion === ThirdPartyVersion.V2 && ( + + )} + + {selectedThirdPartyVersion === ThirdPartyVersion.V1 && ( + + )} + {error ? {error} : null} + + + + +
+
+ ) +} diff --git a/src/components/Modals/CreateLinkedWearablesCollectionModal/CreateLinkedWearablesCollectionModal.types.ts b/src/components/Modals/CreateLinkedWearablesCollectionModal/CreateLinkedWearablesCollectionModal.types.ts new file mode 100644 index 000000000..8845dbf08 --- /dev/null +++ b/src/components/Modals/CreateLinkedWearablesCollectionModal/CreateLinkedWearablesCollectionModal.types.ts @@ -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 +export type MapDispatchProps = Pick +export type MapDispatch = Dispatch diff --git a/src/components/Modals/CreateLinkedWearablesCollectionModal/index.ts b/src/components/Modals/CreateLinkedWearablesCollectionModal/index.ts new file mode 100644 index 000000000..a63c2275d --- /dev/null +++ b/src/components/Modals/CreateLinkedWearablesCollectionModal/index.ts @@ -0,0 +1,3 @@ +export * from './CreateLinkedWearablesCollectionModal.types' +import CreateLinkedWearablesCollectionModal from './CrateLinkedWearablesCollectionModal.container' +export default CreateLinkedWearablesCollectionModal diff --git a/src/components/Modals/EditURNModals/EditCollectionURNModal/EditCollectionURNModal.container.ts b/src/components/Modals/EditURNModals/EditCollectionURNModal/EditCollectionURNModal.container.ts index a4b023ad9..a603740fc 100644 --- a/src/components/Modals/EditURNModals/EditCollectionURNModal/EditCollectionURNModal.container.ts +++ b/src/components/Modals/EditURNModals/EditCollectionURNModal/EditCollectionURNModal.container.ts @@ -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' @@ -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, collectionId: string) => - buildThirdPartyURN(decodedURN.thirdPartyName, collectionId) + onBuildURN: ( + decodedURN: DecodedURN | DecodedURN, + collectionId: string + ) => (isThirdPartyCollectionDecodedUrn(decodedURN) ? buildThirdPartyURN(decodedURN.thirdPartyName, collectionId) : '') }) export default connect(mapState, mapDispatch)(EditURNModal) diff --git a/src/components/Modals/EditURNModals/EditCollectionURNModal/EditCollectionURNModal.types.ts b/src/components/Modals/EditURNModals/EditCollectionURNModal/EditCollectionURNModal.types.ts index a460d3996..21fa55e51 100644 --- a/src/components/Modals/EditURNModals/EditCollectionURNModal/EditCollectionURNModal.types.ts +++ b/src/components/Modals/EditURNModals/EditCollectionURNModal/EditCollectionURNModal.types.ts @@ -11,7 +11,10 @@ export type Props = ModalProps & { metadata: EditURNModalMetadata error: string | null onSave: (urn: string) => ReturnType - onBuildURN: (decodedURN: DecodedURN, collectionId: string) => string + onBuildURN: ( + decodedURN: DecodedURN | DecodedURN, + collectionId: string + ) => string } export type EditURNModalMetadata = { diff --git a/src/components/Modals/EditURNModals/EditItemURNModal/EditItemURNModal.container.ts b/src/components/Modals/EditURNModals/EditItemURNModal/EditItemURNModal.container.ts index 53c9e6409..5dca9356e 100644 --- a/src/components/Modals/EditURNModals/EditItemURNModal/EditItemURNModal.container.ts +++ b/src/components/Modals/EditURNModals/EditItemURNModal/EditItemURNModal.container.ts @@ -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, tokenId: string) => - buildThirdPartyURN(decodedURN.thirdPartyName, decodedURN.thirdPartyCollectionId!, tokenId) + onBuildURN: (decodedURN: DecodedURN | DecodedURN, 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) diff --git a/src/components/Modals/EditURNModals/EditItemURNModal/EditItemURNModal.types.ts b/src/components/Modals/EditURNModals/EditItemURNModal/EditItemURNModal.types.ts index 578eb24f9..f2b4f8976 100644 --- a/src/components/Modals/EditURNModals/EditItemURNModal/EditItemURNModal.types.ts +++ b/src/components/Modals/EditURNModals/EditItemURNModal/EditItemURNModal.types.ts @@ -11,7 +11,10 @@ export type Props = ModalProps & { metadata: EditURNModalMetadata error: string | null onSave: (urn: string) => ReturnType - onBuildURN: (decodedURN: DecodedURN, tokenId: string) => string + onBuildURN: ( + decodedURN: DecodedURN | DecodedURN, + tokenId: string + ) => string } export type EditURNModalMetadata = { diff --git a/src/components/Modals/EditURNModals/EditURNModal/EditURNModal.tsx b/src/components/Modals/EditURNModals/EditURNModal/EditURNModal.tsx index a276f2fb2..c20fbc163 100644 --- a/src/components/Modals/EditURNModals/EditURNModal/EditURNModal.tsx +++ b/src/components/Modals/EditURNModals/EditURNModal/EditURNModal.tsx @@ -7,7 +7,7 @@ import { DecodedURN, decodeURN, isThirdParty, URNType } from 'lib/urn' import { Props, State } from './EditURNModal.types' export default class EditURNModal extends React.PureComponent { - decodedURN: DecodedURN = this.decodeURN() + decodedURN: DecodedURN | DecodedURN = this.decodeURN() analytics = getAnalytics() state: State = { @@ -22,7 +22,11 @@ export default class EditURNModal extends React.PureComponent { const { onSave, urn: oldURN } = this.props const urn = this.getUpdatedURN() if (isThirdParty(urn)) { - const metric = this.decodedURN.thirdPartyCollectionId ? 'Change TP Item URN' : 'Change TP Collection URN' + const metric = + (this.decodedURN.type === URNType.COLLECTIONS_THIRDPARTY && this.decodedURN.thirdPartyCollectionId) || + this.decodedURN.type === URNType.COLLECTIONS_THIRDPARTY_V2 + ? 'Change TP Item URN' + : 'Change TP Collection URN' this.analytics.track(metric, { oldURN, newURN: urn }) } @@ -43,7 +47,7 @@ export default class EditURNModal extends React.PureComponent { decodeURN() { const { urn } = this.props const decodedURN = decodeURN(urn) - if (decodedURN.type !== URNType.COLLECTIONS_THIRDPARTY) { + if (decodedURN.type !== URNType.COLLECTIONS_THIRDPARTY && decodedURN.type !== URNType.COLLECTIONS_THIRDPARTY_V2) { throw new Error(`Invalid URN type ${this.decodedURN.type}`) } return decodedURN diff --git a/src/components/Modals/EditURNModals/EditURNModal/EditURNModal.types.ts b/src/components/Modals/EditURNModals/EditURNModal/EditURNModal.types.ts index 4f9cce736..ebff9b8f7 100644 --- a/src/components/Modals/EditURNModals/EditURNModal/EditURNModal.types.ts +++ b/src/components/Modals/EditURNModals/EditURNModal/EditURNModal.types.ts @@ -10,6 +10,9 @@ export type Props = ModalProps & { urn: URN isLoading: boolean error: string | null - onBuildURN: (decodedURN: DecodedURN, newURNSection: string) => string + onBuildURN: ( + decodedURN: DecodedURN | DecodedURN, + newURNSection: string + ) => string onSave: (newURN: string) => void } diff --git a/src/components/Modals/index.ts b/src/components/Modals/index.ts index 7dbfb9c84..c211b4d4e 100644 --- a/src/components/Modals/index.ts +++ b/src/components/Modals/index.ts @@ -51,3 +51,4 @@ export { default as WorldsForENSOwnersAnnouncementModal } from './WorldsForENSOw export { default as EnsMapAddressModal } from './ENSMapAddressModal' export { default as ReclaimNameModal } from './ReclaimNameModal' export { default as WorldPermissionsModal } from './WorldPermissionsModal' +export { default as CreateLinkedWearablesCollectionModal } from './CreateLinkedWearablesCollectionModal' diff --git a/src/icons/polygon.svg b/src/icons/polygon.svg new file mode 100644 index 000000000..bfbc8a9e7 --- /dev/null +++ b/src/icons/polygon.svg @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/lib/urn.spec.ts b/src/lib/urn.spec.ts index 314107054..aaa75a8e3 100644 --- a/src/lib/urn.spec.ts +++ b/src/lib/urn.spec.ts @@ -11,7 +11,13 @@ import { isThirdParty, extractEntityId, extractCollectionAddress, - extractTokenId + extractTokenId, + buildThirdPartyV2URN, + LinkedContractProtocol, + DecodedURN, + isThirdPartyV2CollectionDecodedUrn, + isThirdPartyCollectionDecodedUrn, + decodedCollectionsUrnAreEqual } from './urn' jest.mock('decentraland-dapps/dist/lib/eth') @@ -71,6 +77,42 @@ describe('when building the third party URN', () => { }) }) +describe('when building the third party v2 URN', () => { + const thirdPartyName = 'some-tp-name' + const linkedNetwork = LinkedContractProtocol.AMOY + const linkedAddress = '0x123123' + + beforeEach(() => { + ;(getChainIdByNetwork as jest.Mock).mockReturnValueOnce(ChainId.MATIC_MAINNET) + }) + + it('should return a valid third party collection urn', () => { + expect(buildThirdPartyV2URN(thirdPartyName, linkedNetwork, linkedAddress)).toBe( + `urn:decentraland:matic:collections-linked-wearables:${thirdPartyName}:${linkedNetwork}:${linkedAddress}` + ) + }) + + it('should get the chain id for the matic network', () => { + buildThirdPartyV2URN(thirdPartyName, linkedNetwork, linkedAddress) + expect(getChainIdByNetwork).toHaveBeenCalledWith(Network.MATIC) + }) + + describe('when supplying a token id', () => { + const tokenId = 'a-wonderful-token-id' + + it('should return a valid third party item urn', () => { + expect(buildThirdPartyV2URN(thirdPartyName, linkedNetwork, linkedAddress, tokenId)).toBe( + `urn:decentraland:matic:collections-linked-wearables:${thirdPartyName}:${linkedNetwork}:${linkedAddress}:${tokenId}` + ) + }) + + it('should get the chain id for the matic network', () => { + buildThirdPartyV2URN(thirdPartyName, linkedNetwork, linkedAddress, tokenId) + expect(getChainIdByNetwork).toHaveBeenCalledWith(Network.MATIC) + }) + }) +}) + describe('when decoding an URN', () => { describe('when the urn is empty', () => { it('should return false', () => { @@ -120,7 +162,7 @@ describe('when decoding an URN', () => { }) }) - describe('when a valid third party', () => { + describe('when a valid third party v1 URN is used', () => { const thirdPartyRecordURN = 'urn:decentraland:matic:collections-thirdparty:crypto-motors' describe('when third party record urn is used', () => { @@ -163,6 +205,52 @@ describe('when decoding an URN', () => { }) }) + describe('when a valid third party v2 URN is used', () => { + const thirdPartyRecordURN = 'urn:decentraland:matic:collections-linked-wearables:crypto-motors' + + describe('when third party record urn is used', () => { + it('should decode and return each group', () => { + expect(decodeURN(thirdPartyRecordURN)).toEqual({ + type: URNType.COLLECTIONS_THIRDPARTY_V2, + protocol: URNProtocol.MATIC, + suffix: 'crypto-motors', + thirdPartyLinkedCollectionName: 'crypto-motors', + linkedCollectionNetwork: undefined, + linkedCollectionContractAddress: undefined, + thirdPartyTokenId: undefined + }) + }) + }) + + describe('when third party collection urn is used', () => { + it('should decode and return each group', () => { + expect(decodeURN(thirdPartyRecordURN + ':amoy:0x74c78f5a4ab22f01d5fd08455cf0ff5c3367535c')).toEqual({ + type: URNType.COLLECTIONS_THIRDPARTY_V2, + protocol: URNProtocol.MATIC, + suffix: 'crypto-motors:amoy:0x74c78f5a4ab22f01d5fd08455cf0ff5c3367535c', + thirdPartyLinkedCollectionName: 'crypto-motors', + linkedCollectionNetwork: 'amoy', + linkedCollectionContractAddress: '0x74c78f5a4ab22f01d5fd08455cf0ff5c3367535c', + thirdPartyTokenId: undefined + }) + }) + }) + + describe('when a third party item urn is used', () => { + it('should decode and return each group', () => { + expect(decodeURN(thirdPartyRecordURN + ':amoy:0x74c78f5a4ab22f01d5fd08455cf0ff5c3367535c:better-token-id')).toEqual({ + type: URNType.COLLECTIONS_THIRDPARTY_V2, + protocol: URNProtocol.MATIC, + suffix: 'crypto-motors:amoy:0x74c78f5a4ab22f01d5fd08455cf0ff5c3367535c:better-token-id', + thirdPartyLinkedCollectionName: 'crypto-motors', + linkedCollectionNetwork: 'amoy', + linkedCollectionContractAddress: '0x74c78f5a4ab22f01d5fd08455cf0ff5c3367535c', + thirdPartyTokenId: 'better-token-id' + }) + }) + }) + }) + describe('when a valid entity urn is used', () => { describe('and the URN is an entity with a baseUrl', () => { it('should decode and return each group', () => { @@ -187,7 +275,7 @@ describe('when decoding an URN', () => { }) }) -describe('when extracting the third party item token id from an URN', () => { +describe('when extracting the third party item token id from a third party v1 URN', () => { describe('when the URN is not a valid third party URN', () => { it("should throw an error signaling that the URN doesn't belong to a third party", () => { expect(() => @@ -198,7 +286,7 @@ describe('when extracting the third party item token id from an URN', () => { }) }) - describe('when the URN is a valid third party URN', () => { + describe('when the URN is a valid third party v1 URN', () => { it('should extract the collection and token ids', () => { expect(extractThirdPartyTokenId('urn:decentraland:mumbai:collections-thirdparty:thirdparty2:collection-id:token-id')).toBe( 'collection-id:token-id' @@ -207,6 +295,28 @@ describe('when extracting the third party item token id from an URN', () => { }) }) +describe('when extracting the third party item token id from a third party v2 URN', () => { + describe('when the URN is not a valid third party URN', () => { + it("should throw an error signaling that the URN doesn't belong to a third party", () => { + expect(() => + extractThirdPartyTokenId('urn:decentraland:matic:collections-v2:0xc6d2000a7a1ddca92941f4e2b41360fe4ee2abd8') + ).toThrowError( + 'Tried to build a third party token for a non third party URN "urn:decentraland:matic:collections-v2:0xc6d2000a7a1ddca92941f4e2b41360fe4ee2abd8"' + ) + }) + }) + + describe('when the URN is a valid third party v2 URN', () => { + it('should extract the collection and token ids', () => { + expect( + extractThirdPartyTokenId( + 'urn:decentraland:matic:collections-linked-wearables:crypto-motors:amoy:0x74c78f5a4ab22f01d5fd08455cf0ff5c3367535c:better-token-id' + ) + ).toBe('amoy:0x74c78f5a4ab22f01d5fd08455cf0ff5c3367535c:better-token-id') + }) + }) +}) + describe('when checking if a collection is a third party', () => { let urn: string @@ -306,3 +416,153 @@ describe('when extracting the token id from an URN', () => { }) }) }) + +describe('when checking if a decoded URN belongs to a third party one', () => { + describe('and the decoded URN does not belong to a third party', () => { + let decodedUrn: DecodedURN + + beforeEach(() => { + decodedUrn = decodeURN('urn:decentraland:goerli:collections-v2:0xc6d2000a7a1ddca92941f4e2b41360fe4ee2abd8') + }) + + it('should return false', () => { + expect(isThirdPartyCollectionDecodedUrn(decodedUrn)).toBe(false) + }) + }) + + describe('and the decoded URN belongs to a third party', () => { + let decodedUrn: DecodedURN + + beforeEach(() => { + decodedUrn = decodeURN('urn:decentraland:mumbai:collections-thirdparty:thirdparty2:collection-id') + }) + + it('should return true', () => { + expect(isThirdPartyCollectionDecodedUrn(decodedUrn)).toBe(true) + }) + }) +}) + +describe('when checking if a decoded URN belongs to a third party v2 one', () => { + describe('and the decoded URN does not belong to a third party v2', () => { + let decodedUrn: DecodedURN + + beforeEach(() => { + decodedUrn = decodeURN('urn:decentraland:goerli:collections-v2:0xc6d2000a7a1ddca92941f4e2b41360fe4ee2abd8') + }) + + it('should return false', () => { + expect(isThirdPartyV2CollectionDecodedUrn(decodedUrn)).toBe(false) + }) + }) + + describe('and the decoded URN belongs to a third party v2', () => { + let decodedUrn: DecodedURN + + beforeEach(() => { + decodedUrn = decodeURN( + 'urn:decentraland:matic:collections-linked-wearables:crypto-motors:amoy:0x74c78f5a4ab22f01d5fd08455cf0ff5c3367535c:better-token-id' + ) + }) + + it('should return true', () => { + expect(isThirdPartyV2CollectionDecodedUrn(decodedUrn)).toBe(true) + }) + }) +}) + +describe('when checking if two decoded collection URNs are equal', () => { + let fistDecodedUrn: DecodedURN + let secondDecodedUrn: DecodedURN + + describe('and the URNs are of collections v2', () => { + describe('and the URNs are different', () => { + beforeEach(() => { + fistDecodedUrn = decodeURN('urn:decentraland:amoy:collections-v2:0xc6d2000a7a1ddca92941f4e2b41360fe4ee2abd8') + secondDecodedUrn = decodeURN('urn:decentraland:amoy:collections-v2:0x16a2040b2b1eeca12344f4e2b11260ae2ee2edc2') + }) + + it('should return false', () => { + expect(decodedCollectionsUrnAreEqual(fistDecodedUrn, secondDecodedUrn)).toBe(false) + }) + }) + + describe('and the URNs are equal', () => { + beforeEach(() => { + fistDecodedUrn = decodeURN('urn:decentraland:amoy:collections-v2:0xc6d2000a7a1ddca92941f4e2b41360fe4ee2abd8') + secondDecodedUrn = decodeURN('urn:decentraland:amoy:collections-v2:0xc6d2000a7a1ddca92941f4e2b41360fe4ee2abd8') + }) + + it('should return true', () => { + expect(decodedCollectionsUrnAreEqual(fistDecodedUrn, secondDecodedUrn)).toBe(true) + }) + }) + }) + + describe('and the URNs are of third party v1 collections', () => { + describe('and the URNs are different', () => { + beforeEach(() => { + fistDecodedUrn = decodeURN('urn:decentraland:amoy:collections-thirdparty:thirdparty2:collection-id') + secondDecodedUrn = decodeURN('urn:decentraland:amoy:collections-thirdparty:thirdparty2:another-collection-id') + }) + + it('should return false', () => { + expect(decodedCollectionsUrnAreEqual(fistDecodedUrn, secondDecodedUrn)).toBe(false) + }) + }) + + describe('and the URNs are equal', () => { + beforeEach(() => { + fistDecodedUrn = decodeURN('urn:decentraland:amoy:collections-thirdparty:thirdparty2:collection-id') + secondDecodedUrn = decodeURN('urn:decentraland:amoy:collections-thirdparty:thirdparty2:collection-id') + }) + + it('should return true', () => { + expect(decodedCollectionsUrnAreEqual(fistDecodedUrn, secondDecodedUrn)).toBe(true) + }) + }) + }) + + describe('and the URNs are of third party v2 collections', () => { + describe('and the URNs are different', () => { + beforeEach(() => { + fistDecodedUrn = decodeURN( + 'urn:decentraland:matic:collections-linked-wearables:crypto-motors:amoy:0x74c78f5a4ab22f01d5fd08455cf0ff5c3367535c' + ) + secondDecodedUrn = decodeURN( + 'urn:decentraland:matic:collections-linked-wearables:crypto-motors:amoy:0x21c28f5a2ab14f11d4fd08425cf0ea5c2367215c' + ) + }) + + it('should return false', () => { + expect(decodedCollectionsUrnAreEqual(fistDecodedUrn, secondDecodedUrn)).toBe(false) + }) + }) + + describe('and the URNs are equal', () => { + beforeEach(() => { + fistDecodedUrn = decodeURN( + 'urn:decentraland:matic:collections-linked-wearables:crypto-motors:amoy:0x74c78f5a4ab22f01d5fd08455cf0ff5c3367535c' + ) + secondDecodedUrn = decodeURN( + 'urn:decentraland:matic:collections-linked-wearables:crypto-motors:amoy:0x74c78f5a4ab22f01d5fd08455cf0ff5c3367535c' + ) + }) + + it('should return true', () => { + expect(decodedCollectionsUrnAreEqual(fistDecodedUrn, secondDecodedUrn)).toBe(true) + }) + }) + }) + + describe('and the URNs are of different types', () => { + beforeEach(() => { + fistDecodedUrn = decodeURN('urn:decentraland:amoy:collections-v2:0xc6d2000a7a1ddca92941f4e2b41360fe4ee2abd8') + secondDecodedUrn = decodeURN('urn:decentraland:amoy:collections-thirdparty:thirdparty2:collection-id') + }) + + it('should return false', () => { + expect(decodedCollectionsUrnAreEqual(fistDecodedUrn, secondDecodedUrn)).toBe(false) + }) + }) +}) diff --git a/src/lib/urn.ts b/src/lib/urn.ts index 38d680760..01de63a12 100644 --- a/src/lib/urn.ts +++ b/src/lib/urn.ts @@ -19,6 +19,7 @@ import { getChainIdByNetwork } from 'decentraland-dapps/dist/lib/eth' * base-avatars| * collections-v2| * collections-thirdparty| + * collections-linked-wearables| * entity * ): * (? @@ -28,19 +29,27 @@ import { getChainIdByNetwork } from 'decentraland-dapps/dist/lib/eth' * (?[^:|\\s]+) * (:(?[^:|\\s]+))? * (:(?[^:|\\s]+))? - * ) + * )| + * ((?<=collections-linked-wearables:) + * (?[^:|\\s]+) + * (:?mainnet|sepolia|matic|amoy) + * (:?0x[a-fA-F0-9]{40})? + * (:(?[^:|\\s]+))?) * ((?<=entity:)(?[^:|\\s]+)?\\?=\\&baseUrl=(?https:[^=\\s]+)?) * ) * ) */ const baseMatcher = 'urn:decentraland' const protocolMatcher = '(?mainnet|goerli|sepolia|matic|mumbai|amoy|off-chain)' -const typeMatcher = '(?base-avatars|collections-v2|collections-thirdparty|entity)' +const typeMatcher = '(?base-avatars|collections-v2|collections-thirdparty|collections-linked-wearables|entity)' const baseAvatarsSuffixMatcher = '((?<=base-avatars:)BaseMale|BaseFemale)' const collectionsSuffixMatcher = '((?<=collections-v2:)(?0x[a-fA-F0-9]{40}))(:(?[^:|\\s]+))?' -const thirdPartySuffixMatcher = - '((?<=collections-thirdparty:)(?[^:|\\s]+)(:(?[^:|\\s]+))?(:(?[^:|\\s]+))?)' +const thirdPartySuffixMatcher = '((?<=collections-thirdparty:)(?[^:|\\s]+)(:(?[^:|\\s]+))?)' +const thirdPartyV2SuffixMatcher = + '((?<=collections-linked-wearables:)(?[^:|\\s]+)(:(?mainnet|sepolia|matic|amoy):(?0x[a-fA-F0-9]{40}))?)' +const thirdPartyMatchers = `(:?${thirdPartySuffixMatcher}|${thirdPartyV2SuffixMatcher})(:(?[^:|\\s]+))?` + const entitySuffixMatcher = '((?<=entity:)(?[^\\?|\\s]+)(\\?=\\&baseUrl=(?[^\\?|\\s]+))?)' export enum URNProtocol { @@ -52,10 +61,17 @@ export enum URNProtocol { AMOY = 'amoy', OFF_CHAIN = 'off-chain' } +export enum LinkedContractProtocol { + MAINNET = 'mainnet', + SEPOLIA = 'sepolia', + MATIC = 'matic', + AMOY = 'amoy' +} export enum URNType { BASE_AVATARS = 'base-avatars', COLLECTIONS_V2 = 'collections-v2', COLLECTIONS_THIRDPARTY = 'collections-thirdparty', + COLLECTIONS_THIRDPARTY_V2 = 'collections-linked-wearables', ENTITY = 'entity' } export type URN = string @@ -72,17 +88,26 @@ type CollectionThirdPartyURN = { thirdPartyCollectionId?: string thirdPartyTokenId?: string } +type CollectionThirdPartyV2URN = { + type: URNType.COLLECTIONS_THIRDPARTY_V2 + thirdPartyLinkedCollectionName: string + linkedCollectionNetwork: LinkedContractProtocol + linkedCollectionContractAddress: string + thirdPartyTokenId?: string +} type EntityURN = { type: URNType.ENTITY; entityId: string; baseUrl?: string } export type DecodedURN = BaseDecodedURN & (T extends URNType.BASE_AVATARS ? BaseAvatarURN : T extends URNType.COLLECTIONS_V2 ? CollectionsV2URN + : T extends URNType.COLLECTIONS_THIRDPARTY_V2 + ? CollectionThirdPartyV2URN : T extends URNType.COLLECTIONS_THIRDPARTY ? CollectionThirdPartyURN : T extends URNType.ENTITY ? EntityURN - : BaseAvatarURN | CollectionsV2URN | CollectionThirdPartyURN | EntityURN) + : BaseAvatarURN | CollectionsV2URN | CollectionThirdPartyURN | CollectionThirdPartyV2URN | EntityURN) export function buildThirdPartyURN(thirdPartyName: string, collectionId: string, tokenId?: string) { let urn = `urn:decentraland:${getNetworkURNProtocol(Network.MATIC)}:collections-thirdparty:${thirdPartyName}:${collectionId}` @@ -92,6 +117,21 @@ export function buildThirdPartyURN(thirdPartyName: string, collectionId: string, return urn } +export function buildThirdPartyV2URN( + thirdPartyName: string, + thirdPartyLinkedContractNetwork: LinkedContractProtocol, + thirdPartyLinkedContractAddress: string, + tokenId?: string +) { + let urn = `urn:decentraland:${getNetworkURNProtocol( + Network.MATIC + )}:collections-linked-wearables:${thirdPartyName}:${thirdPartyLinkedContractNetwork}:${thirdPartyLinkedContractAddress}` + if (tokenId) { + urn += `:${tokenId}` + } + return urn +} + export function buildCatalystItemURN(contractAddress: string, tokenId: string): URN { return `urn:decentraland:${getNetworkURNProtocol(Network.MATIC)}:collections-v2:${contractAddress}:${tokenId}` } @@ -102,21 +142,29 @@ export function buildDefaultCatalystCollectionURN() { export function extractThirdPartyId(urn: URN): string { const decodedURN = decodeURN(urn) - if (decodedURN.type !== URNType.COLLECTIONS_THIRDPARTY) { + if (decodedURN.type !== URNType.COLLECTIONS_THIRDPARTY && decodedURN.type !== URNType.COLLECTIONS_THIRDPARTY_V2) { throw new Error('URN is not a third party URN') } - return `urn:decentraland:${decodedURN.protocol}:collections-thirdparty:${decodedURN.thirdPartyName}` + if (decodedURN.type === URNType.COLLECTIONS_THIRDPARTY) { + return `urn:decentraland:${decodedURN.protocol}:collections-thirdparty:${decodedURN.thirdPartyName}` + } else { + return `urn:decentraland:${decodedURN.protocol}:collections-linked-wearables:${decodedURN.thirdPartyLinkedCollectionName}` + } } export function extractThirdPartyTokenId(urn: URN) { const decodedURN = decodeURN(urn) - if (decodedURN.type !== URNType.COLLECTIONS_THIRDPARTY) { - throw new Error(`Tried to build a third party token for a non third party URN "${urn}"`) + + if (decodedURN.type === URNType.COLLECTIONS_THIRDPARTY) { + const { thirdPartyCollectionId, thirdPartyTokenId } = decodedURN + return `${thirdPartyCollectionId ?? ''}:${thirdPartyTokenId ?? ''}` + } else if (decodedURN.type === URNType.COLLECTIONS_THIRDPARTY_V2) { + const { linkedCollectionNetwork, linkedCollectionContractAddress, thirdPartyTokenId } = decodedURN + return `${linkedCollectionNetwork}:${linkedCollectionContractAddress}:${thirdPartyTokenId ?? ''}` } - const { thirdPartyCollectionId, thirdPartyTokenId } = decodedURN - return `${thirdPartyCollectionId ?? ''}:${thirdPartyTokenId ?? ''}` + throw new Error(`Tried to build a third party token for a non third party URN "${urn}"`) } // TODO: This logic is repeated in collection/util's `getCollectionType`, but being used only for items (item.urn). @@ -127,7 +175,7 @@ export function isThirdParty(urn?: string) { } const decodedURN = decodeURN(urn) - return decodedURN.type === URNType.COLLECTIONS_THIRDPARTY + return decodedURN.type === URNType.COLLECTIONS_THIRDPARTY || decodedURN.type === URNType.COLLECTIONS_THIRDPARTY_V2 } export function extractEntityId(urn: URN): string { @@ -141,7 +189,7 @@ export function extractEntityId(urn: URN): string { export function decodeURN(urn: URN): DecodedURN { const urnRegExp = new RegExp( - `${baseMatcher}:(${protocolMatcher}:)?${typeMatcher}:(?${baseAvatarsSuffixMatcher}|${collectionsSuffixMatcher}|${thirdPartySuffixMatcher}|${entitySuffixMatcher})` + `${baseMatcher}:(${protocolMatcher}:)?${typeMatcher}:(?${baseAvatarsSuffixMatcher}|${collectionsSuffixMatcher}|${thirdPartyMatchers}|${entitySuffixMatcher})` ) const matches = urnRegExp.exec(urn) @@ -181,3 +229,47 @@ export function extractTokenId(urn: URN): string { return `${collectionAddress}:${tokenId ?? ''}` } + +export const decodedCollectionsUrnAreEqual = (urnA: DecodedURN, urnB: DecodedURN) => { + if (urnA.type !== urnB.type) { + return false + } + + switch (urnA.type) { + case URNType.COLLECTIONS_V2: + return ( + urnA.collectionAddress === (urnB as DecodedURN).collectionAddress && + urnA.tokenId === (urnB as DecodedURN).tokenId + ) + case URNType.COLLECTIONS_THIRDPARTY: + return ( + urnA.thirdPartyName === (urnB as DecodedURN).thirdPartyName && + urnA.thirdPartyCollectionId === (urnB as DecodedURN).thirdPartyCollectionId && + urnA.thirdPartyTokenId === (urnB as DecodedURN).thirdPartyTokenId + ) + case URNType.COLLECTIONS_THIRDPARTY_V2: + return ( + urnA.thirdPartyLinkedCollectionName === (urnB as DecodedURN).thirdPartyLinkedCollectionName && + urnA.linkedCollectionNetwork === (urnB as DecodedURN).linkedCollectionNetwork && + urnA.linkedCollectionContractAddress === (urnB as DecodedURN).linkedCollectionContractAddress && + urnA.thirdPartyTokenId === (urnB as DecodedURN).thirdPartyTokenId + ) + } +} + +export const isThirdPartyCollectionDecodedUrn = ( + urn: DecodedURN +): urn is DecodedURN & { thirdPartyName: string; thirdPartyCollectionId: string } => + urn.type === URNType.COLLECTIONS_THIRDPARTY && !!urn.thirdPartyName && !!urn.thirdPartyCollectionId + +export const isThirdPartyV2CollectionDecodedUrn = ( + urn: DecodedURN +): urn is DecodedURN & { + thirdPartyLinkedCollectionName: string + linkedCollectionNetwork: string + linkedCollectionAddress: string +} => + urn.type === URNType.COLLECTIONS_THIRDPARTY_V2 && + !!urn.thirdPartyLinkedCollectionName && + !!urn.linkedCollectionNetwork && + !!urn.linkedCollectionContractAddress diff --git a/src/modules/collection/sagas.spec.ts b/src/modules/collection/sagas.spec.ts index dee837c9f..f6213b0b8 100644 --- a/src/modules/collection/sagas.spec.ts +++ b/src/modules/collection/sagas.spec.ts @@ -1259,29 +1259,29 @@ describe('when saving a collection', () => { return expectSaga(collectionSaga, mockBuilder, mockBuilderClient) .provide([ [getContext('history'), { push: pushMock }], - [select(getOpenModals), { CreateThirdPartyCollectionModal: true }], + [select(getOpenModals), { CreateThirdPartyCollectionModal: true, CreateLinkedWearablesCollectionModal: true }], [call([mockBuilder, 'saveCollection'], thirdPartyCollection, ''), remoteCollection] ]) .dispatch(saveCollectionRequest(thirdPartyCollection)) - .dispatch(closeModal('CreateThirdPartyCollectionModal')) .run({ silenceTimeout: true }) .then(() => { expect(pushMock).toHaveBeenCalledWith(locations.thirdPartyCollectionDetail(thirdPartyCollection.id)) }) }) - it('should save the collection without data', () => { + it('should save the collection and close all related modals', () => { return expectSaga(collectionSaga, mockBuilder, mockBuilderClient) .provide([ [select(getOpenModals), {}], [call([mockBuilder, 'saveCollection'], thirdPartyCollection, ''), remoteCollection] ]) .put(saveCollectionSuccess({ ...thirdPartyCollection, contractAddress: remoteCollection.contractAddress })) + .put(closeModal('CreateCollectionModal')) + .put(closeModal('CreateThirdPartyCollectionModal')) + .put(closeModal('CreateLinkedWearablesCollectionModal')) + .put(closeModal('EditCollectionURNModal')) + .put(closeModal('EditCollectionNameModal')) .dispatch(saveCollectionRequest(thirdPartyCollection)) - .dispatch(closeModal('CreateCollectionModal')) - .dispatch(closeModal('CreateThirdPartyCollectionModal')) - .dispatch(closeModal('EditCollectionURNModal')) - .dispatch(closeModal('EditCollectionNameModal')) .run({ silenceTimeout: true }) }) }) diff --git a/src/modules/collection/sagas.ts b/src/modules/collection/sagas.ts index c9ab09e63..065bae60b 100644 --- a/src/modules/collection/sagas.ts +++ b/src/modules/collection/sagas.ts @@ -106,14 +106,14 @@ import { FetchRaritiesFailureAction, FetchRaritiesSuccessAction } from 'modules/item/actions' -import { areSynced, isValidText, toInitializeItems } from 'modules/item/utils' +import { areSynced, isEmote, isValidText, isWearable, toInitializeItems } from 'modules/item/utils' import { locations } from 'routing/locations' import { getCollectionId } from 'modules/location/selectors' import { BuilderAPI, FetchCollectionsParams } from 'lib/api/builder' import { getArrayOfPagesFromTotal, PaginatedResource } from 'lib/api/pagination' import { extractThirdPartyId } from 'lib/urn' import { closeModal, CloseModalAction, CLOSE_MODAL, openModal } from 'decentraland-dapps/dist/modules/modal/actions' -import { EntityHashingType, isEmoteItemType, Item, ItemApprovalData, ItemType } from 'modules/item/types' +import { EntityHashingType, isEmoteItemType, Item, ItemApprovalData } from 'modules/item/types' import { Slot } from 'modules/thirdParty/types' import { getEntityByItemId, @@ -247,7 +247,11 @@ export function* collectionSaga(legacyBuilderClient: BuilderAPI, client: Builder const openModals: ModalState = yield select(getOpenModals) const history: History = yield getContext('history') - if (openModals['CreateCollectionModal'] || openModals['CreateThirdPartyCollectionModal']) { + if ( + openModals['CreateCollectionModal'] || + openModals['CreateThirdPartyCollectionModal'] || + openModals['CreateLinkedWearablesCollectionModal'] + ) { // Redirect to the newly created collection detail const { collection } = action.payload const detailPageLocation = isTPCollection(collection) ? locations.thirdPartyCollectionDetail : locations.collectionDetail @@ -258,6 +262,7 @@ export function* collectionSaga(legacyBuilderClient: BuilderAPI, client: Builder yield put(closeModal('CreateCollectionModal')) yield put(closeModal('CreateThirdPartyCollectionModal')) yield put(closeModal('EditCollectionURNModal')) + yield put(closeModal('CreateLinkedWearablesCollectionModal')) yield put(closeModal('EditCollectionNameModal')) } @@ -347,8 +352,8 @@ export function* collectionSaga(legacyBuilderClient: BuilderAPI, client: Builder const { items, email, subscribeToNewsletter, paymentMethod } = action.payload if (subscribeToNewsletter) { - const collectionHasEmotes = items.some(item => item.type === ItemType.EMOTE) - const collectionHasWearables = items.some(item => item.type === ItemType.WEARABLE) + const collectionHasEmotes = items.some(isEmote) + const collectionHasWearables = items.some(isWearable) yield put( subscribeToNewsletterRequest( email, diff --git a/src/modules/collection/selectors.spec.ts b/src/modules/collection/selectors.spec.ts index d20c7fe36..e05bcdd71 100644 --- a/src/modules/collection/selectors.spec.ts +++ b/src/modules/collection/selectors.spec.ts @@ -223,6 +223,7 @@ describe('when getting the authorized collections', () => { [thirdPartyId]: { id: thirdPartyId, managers: [address], + contracts: [], name: 'aName', description: 'aDescription', maxItems: '120', diff --git a/src/modules/collection/utils.ts b/src/modules/collection/utils.ts index 2895e2309..614e43670 100644 --- a/src/modules/collection/utils.ts +++ b/src/modules/collection/utils.ts @@ -84,6 +84,7 @@ export function getCollectionType(collection: Collection): CollectionType { switch (type) { case URNType.COLLECTIONS_THIRDPARTY: + case URNType.COLLECTIONS_THIRDPARTY_V2: return CollectionType.THIRD_PARTY case URNType.COLLECTIONS_V2: case URNType.BASE_AVATARS: diff --git a/src/modules/curations/itemCuration/reducer.spec.ts b/src/modules/curations/itemCuration/reducer.spec.ts index f55f8550a..282e8b98e 100644 --- a/src/modules/curations/itemCuration/reducer.spec.ts +++ b/src/modules/curations/itemCuration/reducer.spec.ts @@ -44,6 +44,7 @@ describe('when an action of type PUBLISH_THIRD_PARTY_ITEMS_REQUEST is called', ( name: 'a third party', description: 'some desc', managers: ['0x1', '0x2'], + contracts: [], maxItems: '0', totalItems: '0' } @@ -73,6 +74,7 @@ describe('when an action of type PUBLISH_AND_PUSH_CHANGES_THIRD_PARTY_ITEMS_REQU name: 'a third party', description: 'some desc', managers: ['0x1', '0x2'], + contracts: [], maxItems: '0', totalItems: '0' } @@ -99,6 +101,7 @@ describe('when an action of type PUBLISH_THIRD_PARTY_ITEMS_SUCCESS is called', ( name: 'a third party', description: 'some desc', managers: ['0x1', '0x2'], + contracts: [], maxItems: '0', totalItems: '0' } @@ -181,6 +184,7 @@ describe('when an action of type PUBLISH_THIRD_PARTY_ITEMS_FAILURE is called', ( name: 'a third party', description: 'some desc', managers: ['0x1', '0x2'], + contracts: [], maxItems: '0', totalItems: '0' } @@ -206,6 +210,7 @@ describe('when an action of type PUBLISH_AND_PUSH_CHANGES_THIRD_PARTY_ITEMS_FAIL name: 'a third party', description: 'some desc', managers: ['0x1', '0x2'], + contracts: [], maxItems: '0', totalItems: '0' } diff --git a/src/modules/features/selectors.spec.ts b/src/modules/features/selectors.spec.ts index f0515502a..6170f434c 100644 --- a/src/modules/features/selectors.spec.ts +++ b/src/modules/features/selectors.spec.ts @@ -3,6 +3,7 @@ import { ApplicationName } from 'decentraland-dapps/dist/modules/features/types' import { RootState } from 'modules/common/types' import { getIsCreateSceneOnlySDK7Enabled, + getIsLinkedWearablesV2Enabled, getIsMaintenanceEnabled, getIsPublishCollectionsWertEnabled, getIsVrmOptOutEnabled, @@ -65,7 +66,8 @@ const ffSelectors = [ { selector: getIsPublishCollectionsWertEnabled, app: ApplicationName.BUILDER, feature: FeatureName.PUBLISH_COLLECTIONS_WERT }, { selector: getIsVrmOptOutEnabled, app: ApplicationName.BUILDER, feature: FeatureName.VRM_OPTOUT }, { selector: getIsWearableUtilityEnabled, app: ApplicationName.DAPPS, feature: FeatureName.WEARABLE_UTILITY }, - { selector: getIsWorldContributorEnabled, app: ApplicationName.BUILDER, feature: FeatureName.WORLD_CONTRIBUTOR } + { selector: getIsWorldContributorEnabled, app: ApplicationName.BUILDER, feature: FeatureName.WORLD_CONTRIBUTOR }, + { selector: getIsLinkedWearablesV2Enabled, app: ApplicationName.BUILDER, feature: FeatureName.LINKED_WEARABLES_V2 } ] ffSelectors.forEach(({ selector, app, feature }) => { diff --git a/src/modules/features/selectors.ts b/src/modules/features/selectors.ts index 3f67aa35a..06cba1695 100644 --- a/src/modules/features/selectors.ts +++ b/src/modules/features/selectors.ts @@ -61,3 +61,11 @@ export const getIsWorldContributorEnabled = (state: RootState) => { return false } } + +export const getIsLinkedWearablesV2Enabled = (state: RootState) => { + try { + return getIsFeatureEnabled(state, ApplicationName.BUILDER, FeatureName.LINKED_WEARABLES_V2) + } catch (e) { + return false + } +} diff --git a/src/modules/features/types.ts b/src/modules/features/types.ts index d31ac9a7b..54f2dd44c 100644 --- a/src/modules/features/types.ts +++ b/src/modules/features/types.ts @@ -7,5 +7,6 @@ export enum FeatureName { PUBLISH_COLLECTIONS_WERT = 'publish-collections-wert', VRM_OPTOUT = 'vrm-optout', WEARABLE_UTILITY = 'wearable-utility', - WORLD_CONTRIBUTOR = 'world-contributor' + WORLD_CONTRIBUTOR = 'world-contributor', + LINKED_WEARABLES_V2 = 'linked-wearables-v2' } diff --git a/src/modules/item/utils.spec.ts b/src/modules/item/utils.spec.ts index 974bff610..6d02074b8 100644 --- a/src/modules/item/utils.spec.ts +++ b/src/modules/item/utils.spec.ts @@ -11,7 +11,8 @@ import { isSmart, getFirstWearableOrItem, formatExtensions, - hasVideo + hasVideo, + isEmote } from './utils' describe('when transforming third party items to be sent to a contract method', () => { @@ -349,3 +350,79 @@ describe('when getting if the item has a video', () => { }) }) }) + +describe('when checking if an item is of a wearable type', () => { + let item: Item + + beforeEach(() => { + item = { + type: ItemType.WEARABLE, + name: 'first-name', + contents: {} + } as Item + }) + + describe('and the item is of a wearable type', () => { + beforeEach(() => { + item = { + ...item, + type: ItemType.WEARABLE + } + }) + + it('should return true', () => { + expect(isEmote(item)).toBe(false) + }) + }) + + describe('and the item is of a emote type', () => { + beforeEach(() => { + item = { + ...item, + type: ItemType.EMOTE + } + }) + + it('should return false', () => { + expect(isEmote(item)).toBe(true) + }) + }) +}) + +describe('when checking if an item is of emote type', () => { + let item: Item + + beforeEach(() => { + item = { + type: ItemType.EMOTE, + name: 'first-name', + contents: {} + } as Item + }) + + describe('and the item is of a emote type', () => { + beforeEach(() => { + item = { + ...item, + type: ItemType.EMOTE + } + }) + + it('should return true', () => { + expect(isEmote(item)).toBe(true) + }) + }) + + describe('and the item is of a wearable type', () => { + beforeEach(() => { + item = { + ...item, + type: ItemType.WEARABLE + } + }) + + it('should return false', () => { + expect(isEmote(item)).toBe(false) + }) + }) +}) diff --git a/src/modules/item/utils.ts b/src/modules/item/utils.ts index 25459f39c..60be99ef5 100644 --- a/src/modules/item/utils.ts +++ b/src/modules/item/utils.ts @@ -240,7 +240,7 @@ export function getMetadata(item: Item) { } } -export function toItemObject(items: Item[]) { +export function toItemObject(items: Item[]) { return items.reduce((obj, item) => { const { collection, ...itemWithoutCollection } = item as Item & { collection?: Collection } obj[item.id] = itemWithoutCollection @@ -602,7 +602,7 @@ export function isEmoteSynced(item: Item | Item, entity: Entity) } export function areSynced(item: Item, entity: Entity) { - return item.type === ItemType.WEARABLE ? isWearableSynced(item, entity) : isEmoteSynced(item, entity) + return isWearable(item) ? isWearableSynced(item, entity) : isEmoteSynced(item, entity) } export function isAllowedToPushChanges(item: Item, status: SyncStatus, itemCuration: ItemCuration | undefined) { @@ -689,7 +689,7 @@ export const loadVideo = (src: File | string): Promise => { } export const getFirstWearableOrItem = (items: Item[]): Item | undefined => { - return items.length > 0 ? items.find(item => item.type === ItemType.WEARABLE) ?? items[0] : undefined + return items.length > 0 ? items.find(isWearable) ?? items[0] : undefined } export const formatExtensions = (extensions: string[]): string => { @@ -708,3 +708,7 @@ export const formatExtensions = (extensions: string[]): string => { return formattedExtensions.join(', ') } + +export const isWearable = (item: Item): item is Item => + item.type === ItemType.WEARABLE +export const isEmote = (item: Item): item is Item => item.type === ItemType.EMOTE diff --git a/src/modules/thirdParty/reducer.spec.ts b/src/modules/thirdParty/reducer.spec.ts index 2fa565c34..aa074352b 100644 --- a/src/modules/thirdParty/reducer.spec.ts +++ b/src/modules/thirdParty/reducer.spec.ts @@ -32,6 +32,7 @@ describe('when an action of type FETCH_THIRD_PARTIES_SUCCESS is called', () => { name: 'a third party', description: 'some desc', managers: ['0x1', '0x2'], + contracts: [], maxItems: '0', totalItems: '0' } diff --git a/src/modules/thirdParty/sagas.spec.ts b/src/modules/thirdParty/sagas.spec.ts index 13293df35..11ed72b62 100644 --- a/src/modules/thirdParty/sagas.spec.ts +++ b/src/modules/thirdParty/sagas.spec.ts @@ -83,6 +83,7 @@ beforeEach(() => { name: 'test', description: 'aDescription', managers: [], + contracts: [], maxItems: '1', totalItems: '1' } @@ -132,9 +133,10 @@ describe('when fetching third parties', () => { description: 'some desc', managers: ['0x1', '0x2'], maxItems: '0', - totalItems: '0' + totalItems: '0', + contracts: [] }, - { id: '2', name: 'a third party', description: 'some desc', managers: ['0x3'], maxItems: '0', totalItems: '0' } + { id: '2', name: 'a third party', description: 'some desc', managers: ['0x3'], maxItems: '0', totalItems: '0', contracts: [] } ] }) diff --git a/src/modules/thirdParty/selectors.spec.ts b/src/modules/thirdParty/selectors.spec.ts index f500198dd..76d551efc 100644 --- a/src/modules/thirdParty/selectors.spec.ts +++ b/src/modules/thirdParty/selectors.spec.ts @@ -25,6 +25,7 @@ describe('Third Party selectors', () => { description: 'some desc', maxItems: '0', totalItems: '0', + contracts: [], managers: [address, '0xa'] } thirdParty2 = { @@ -33,6 +34,7 @@ describe('Third Party selectors', () => { description: 'some desc', maxItems: '0', totalItems: '0', + contracts: [], managers: [address, '0xb'] } thirdParty3 = { @@ -41,6 +43,7 @@ describe('Third Party selectors', () => { description: 'some desc', maxItems: '0', totalItems: '0', + contracts: [], managers: ['0xc'] } baseState = { diff --git a/src/modules/thirdParty/types.ts b/src/modules/thirdParty/types.ts index 485dcb718..605999304 100644 --- a/src/modules/thirdParty/types.ts +++ b/src/modules/thirdParty/types.ts @@ -1,13 +1,21 @@ +import { LinkedContractProtocol } from 'lib/urn' + export type ThirdParty = { id: string managers: string[] name: string description: string + contracts: LinkedContract[] maxItems: string totalItems: string availableSlots?: number } +export type LinkedContract = { + address: string + network: LinkedContractProtocol +} + export type Cheque = { signature: string qty: number @@ -21,3 +29,8 @@ export type Slot = { sigS: string sigV: number } + +export enum ThirdPartyVersion { + V1 = 1, + V2 = 2 +} diff --git a/src/modules/thirdParty/utils.ts b/src/modules/thirdParty/utils.ts index 02446c75a..030a637b6 100644 --- a/src/modules/thirdParty/utils.ts +++ b/src/modules/thirdParty/utils.ts @@ -7,12 +7,15 @@ import { ContractData, ContractName, getContract } from 'decentraland-transactio import { extractThirdPartyId } from 'lib/urn' import { Collection } from 'modules/collection/types' import { Item } from 'modules/item/types' -import { ThirdParty } from './types' +import { ThirdParty, ThirdPartyVersion } from './types' export function isUserManagerOfThirdParty(address: string, thirdParty: ThirdParty): boolean { return thirdParty.managers.map(manager => manager.toLowerCase()).includes(address.toLowerCase()) } +export const getThirdPartyVersion = (thirdParty: ThirdParty): number => + thirdParty.id.split(':')[3] === 'collections-linked-wearables' ? ThirdPartyVersion.V2 : ThirdPartyVersion.V1 + export const getThirdPartyForCollection = (thirdParties: Record, collection: Collection): ThirdParty | undefined => thirdParties[extractThirdPartyId(collection.urn)] diff --git a/src/modules/translation/languages/en.json b/src/modules/translation/languages/en.json index 6003f5467..e77640532 100644 --- a/src/modules/translation/languages/en.json +++ b/src/modules/translation/languages/en.json @@ -197,6 +197,25 @@ "message": "The name can be {maxLength} characters max", "error_name_already_in_use": "Name already in use. Try a different one" }, + "create_linked_wearable_collection_modal": { + "title": "New Linked Wearables Collection", + "subtitle": "Special Wearables linked to NFTs.", + "third_party_field": { + "label": "Third Party" + }, + "linked_contract_field": { + "label": "Smart Contract Address", + "message": "No contracts available. Ask the DAO committee to to add one." + }, + "name_field": { + "label": "Name", + "message": "The collection name can't contain the ':' character." + }, + "collection_id_field": { + "label": "Id", + "message": "We recommend including the collection contract address as part of the id. Only letters, numbers and dashes (-) are allowed" + } + }, "create_third_party_collection_modal": { "title": "New Linked Wearables Collection", "subtitle": "Enter a descriptive name for your new collection", diff --git a/src/modules/translation/languages/es.json b/src/modules/translation/languages/es.json index 345181b5c..ea05f03bc 100644 --- a/src/modules/translation/languages/es.json +++ b/src/modules/translation/languages/es.json @@ -200,6 +200,25 @@ "message": "El nombre puede tener un máximo de {maxLength} caracteres", "error_name_already_in_use": "Nombre en uso. Prueba uno diferente" }, + "create_linked_wearable_collection_modal": { + "title": "Nueva colección de Wearables vinculados", + "subtitle": "Wearables especiales vinculados a NFTs.", + "third_party_field": { + "label": "Proveedor" + }, + "linked_contract_field": { + "label": "Dirección del smart contract", + "message": "No tienes contratos disponibles. Ponte en contacto con un DAO committee para agregar uno." + }, + "name_field": { + "label": "Nombre", + "message": "El nombre de la colección no puede contener el caracter ':'." + }, + "collection_id_field": { + "label": "Id", + "message": "Recomendamos incluir la dirección del contrato de la colección como parte del id. Solo puede usar letras, números y guiones (-)" + } + }, "create_third_party_collection_modal": { "title": "Nueva colleción externa", "subtitle": "Ponle un nombre descriptivo a tu nueva colleción", diff --git a/src/modules/translation/languages/zh.json b/src/modules/translation/languages/zh.json index 95ca36195..f9456f5ce 100644 --- a/src/modules/translation/languages/zh.json +++ b/src/modules/translation/languages/zh.json @@ -195,6 +195,24 @@ "message": "名称最多可以是 {maxLength} 个字符", "error_name_already_in_use": "使用中的名称。 尝试其他" }, + "create_linked_wearable_collection_modal": { + "title": "新的链接可穿戴设备集合", + "subtitle": "与 NFT 挂钩的特殊可穿戴设备", + "third_party_field": { + "label": "第三方提供商" + }, + "linked_contract_field": { + "label": "智能合约地址", + "message": "没有可用的合约。请 DAO 委员会添加一份。" + }, + "name_field": { + "label": "姓名" + }, + "collection_id_field": { + "label": "Id", + "message": "我们建议将收款合约地址作为 id 的一部分。只允许使用字母、数字和破折号 (-)" + } + }, "create_third_party_collection_modal": { "title": "新的链接可穿戴设备系列", "subtitle": "为您的新集合输入一个描述性名称", @@ -203,7 +221,7 @@ }, "name_field": { "label": "姓名", - "message": "名称最多可以是 {maxLength} 个字符" + "message": "集合名称不能包含“:”字符" }, "urn_suffix_field": { "label": "Id",