From 22fa9b9c17cb4bdb23eb2f6cf8dc6f1b25111d67 Mon Sep 17 00:00:00 2001 From: Lautaro Petaccio <1120791+LautaroPetaccio@users.noreply.github.com> Date: Fri, 12 Jul 2024 17:41:42 -0300 Subject: [PATCH] feat: Mapping tokens to wearables (#3138) * feat: Mapping tokens to wearables * fix: Types and tests * fix: Types * fix: Word break --- package-lock.json | 34 ++-- package.json | 8 +- .../CollectionStatus/CollectionStatus.tsx | 5 +- .../CollectionRow/CollectionRow.tsx | 11 +- .../ItemEditorPage/LeftPanel/LeftPanel.tsx | 8 +- .../MappingEditor/MappingEditor.module.css | 17 ++ .../MappingEditor/MappingEditor.tsx | 158 ++++++++++++++++++ .../MappingEditor/MappingEditor.types.ts | 8 + .../ApprovalFlowModal/ApprovalFlowModal.css | 1 + .../CreateSingleItemModal.css | 5 +- .../CreateSingleItemModal.tsx | 52 +++++- .../CreateSingleItemModal.types.ts | 3 +- .../EditCollectionNameModal.tsx | 11 +- ...hirdPartyCollectionDetailPage.container.ts | 8 +- src/icons/all.svg | 4 + src/icons/multiple.svg | 4 + src/icons/range.svg | 4 + src/icons/single.svg | 3 + src/lib/api/builder.ts | 25 ++- src/lib/api/transformations.spec.ts | 6 +- src/lib/api/transformations.ts | 3 +- src/lib/urn.ts | 6 +- src/modules/collection/selectors.ts | 3 +- src/modules/collection/types.ts | 3 +- src/modules/collection/utils.spec.ts | 6 +- src/modules/collection/utils.ts | 8 +- src/modules/item/export.ts | 7 +- src/modules/item/types.ts | 3 +- src/modules/location/selectors.ts | 5 +- src/modules/translation/languages/en.json | 19 ++- src/modules/translation/languages/es.json | 19 ++- src/modules/translation/languages/zh.json | 19 ++- src/routing/locations.ts | 1 + src/specs/item.ts | 9 +- 34 files changed, 394 insertions(+), 92 deletions(-) create mode 100644 src/components/MappingEditor/MappingEditor.module.css create mode 100644 src/components/MappingEditor/MappingEditor.tsx create mode 100644 src/components/MappingEditor/MappingEditor.types.ts create mode 100644 src/icons/all.svg create mode 100644 src/icons/multiple.svg create mode 100644 src/icons/range.svg create mode 100644 src/icons/single.svg diff --git a/package-lock.json b/package-lock.json index 9efdcdd80..7ceec26db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,13 +11,13 @@ "dependencies": { "@babylonjs/core": "^4.2.0", "@babylonjs/loaders": "^4.2.0", - "@dcl/builder-client": "^4.3.3", + "@dcl/builder-client": "^4.4.0", "@dcl/builder-templates": "^0.2.0", "@dcl/content-hash-tree": "^1.1.3", "@dcl/crypto": "^3.4.5", "@dcl/hashing": "^3.0.4", "@dcl/mini-rpc": "^1.0.7", - "@dcl/schemas": "^11.7.0", + "@dcl/schemas": "^11.12.0", "@dcl/sdk": "7.5.5", "@dcl/single-sign-on-client": "^0.1.0", "@dcl/ui-env": "^1.5.0", @@ -48,7 +48,7 @@ "decentraland-ecs": "6.12.4-7784644013.commit-f770b3e", "decentraland-experiments": "^1.0.2", "decentraland-transactions": "^2.6.1", - "decentraland-ui": "^6.1.1", + "decentraland-ui": "^6.1.2", "ethers": "^5.6.8", "file-saver": "^2.0.1", "graphql": "^15.8.0", @@ -2261,16 +2261,16 @@ } }, "node_modules/@dcl/builder-client": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@dcl/builder-client/-/builder-client-4.3.3.tgz", - "integrity": "sha512-QK/5djJIighIGuK3oSsINk46cJrC+xe9beDrzaP1m91FWv42aC5BX3wFcXga1geP/jjx6atYl3BvnsTWFXfyCQ==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@dcl/builder-client/-/builder-client-4.4.0.tgz", + "integrity": "sha512-T09X9dkBfqo87JinBh2QYEN4m+KV71oQIebl6pOyhcP3NQgYr8o2HyMGp0ewmKzDTE7U1MBqIgqqbBAllUmN0w==", "dependencies": { "@dcl/crypto": "^3.0.1", "@dcl/hashing": "^1.1.0", - "@dcl/schemas": "^11.4.0", + "@dcl/schemas": "^11.12.0", "ajv": "^8.11.0", "ajv-errors": "^3.0.0", - "ajv-formats": "^2.1.1", + "ajv-formats": "^3.0.1", "ajv-keywords": "^5.1.0", "cross-fetch": "^3.1.4", "ethers": "^5.5.2", @@ -2723,9 +2723,9 @@ } }, "node_modules/@dcl/schemas": { - "version": "11.9.0", - "resolved": "https://registry.npmjs.org/@dcl/schemas/-/schemas-11.9.0.tgz", - "integrity": "sha512-IIKsy45VhiB9rD6v1d3UedyApBE8EmtAiIWevXIvam1w9Nsh+w8lWAWtD/HX3fXivBwLDZGrn1IRUK4dI4WlHA==", + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/@dcl/schemas/-/schemas-11.12.0.tgz", + "integrity": "sha512-L04KTucvxSnrHDAl3/rnkzhjfZ785dSSPeKarBVfzyuw41uyQ0Mh4HVFWjX9hC+f/nMpM5Adg5udlT5efmepcA==", "dependencies": { "ajv": "^8.11.0", "ajv-errors": "^3.0.0", @@ -8451,9 +8451,9 @@ } }, "node_modules/ajv-formats": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", - "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", "dependencies": { "ajv": "^8.0.0" }, @@ -11813,9 +11813,9 @@ } }, "node_modules/decentraland-ui": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/decentraland-ui/-/decentraland-ui-6.1.1.tgz", - "integrity": "sha512-KYovAGqdIyIhV/B+AcroyFKPLb2t+g9q1c92j8kiqsaGqjxKpbO4C8dAbcdCYMmKrlMJN09RN+QrMxD2X7nrqw==", + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/decentraland-ui/-/decentraland-ui-6.1.2.tgz", + "integrity": "sha512-7llefcVjaSBCZfBGBOU2ixhRLFjyPaoQXWCsj3gzIWuLRG94VPm71DwobF+/P3z5RphbXMbKF1yZVWpaZ81T9Q==", "dependencies": { "@dcl/schemas": "^11.9.0", "@dcl/ui-env": "^1.4.0", diff --git a/package.json b/package.json index b07db3c9f..958d4f0c7 100644 --- a/package.json +++ b/package.json @@ -5,13 +5,13 @@ "dependencies": { "@babylonjs/core": "^4.2.0", "@babylonjs/loaders": "^4.2.0", - "@dcl/builder-client": "^4.3.3", + "@dcl/builder-client": "^4.4.0", "@dcl/builder-templates": "^0.2.0", "@dcl/content-hash-tree": "^1.1.3", "@dcl/crypto": "^3.4.5", "@dcl/hashing": "^3.0.4", "@dcl/mini-rpc": "^1.0.7", - "@dcl/schemas": "^11.7.0", + "@dcl/schemas": "^11.12.0", "@dcl/sdk": "7.5.5", "@dcl/single-sign-on-client": "^0.1.0", "@dcl/ui-env": "^1.5.0", @@ -42,7 +42,7 @@ "decentraland-ecs": "6.12.4-7784644013.commit-f770b3e", "decentraland-experiments": "^1.0.2", "decentraland-transactions": "^2.6.1", - "decentraland-ui": "^6.1.1", + "decentraland-ui": "^6.1.2", "ethers": "^5.6.8", "file-saver": "^2.0.1", "graphql": "^15.8.0", @@ -158,4 +158,4 @@ "decentraland-ecs": "6.12.4-7784644013.commit-f770b3e", "decentraland": "3.3.0" } -} +} \ No newline at end of file diff --git a/src/components/CollectionStatus/CollectionStatus.tsx b/src/components/CollectionStatus/CollectionStatus.tsx index b78c2a446..19f818893 100644 --- a/src/components/CollectionStatus/CollectionStatus.tsx +++ b/src/components/CollectionStatus/CollectionStatus.tsx @@ -3,15 +3,14 @@ import { t } from 'decentraland-dapps/dist/modules/translation/utils' import { Popup } from 'decentraland-ui' import { SyncStatus } from 'modules/item/types' +import { isThirdPartyCollection } from 'modules/collection/utils' import { Props } from './CollectionStatus.types' -import { getCollectionType } from 'modules/collection/utils' -import { CollectionType } from 'modules/collection/types' import './CollectionStatus.css' export default class CollectionStatus extends React.PureComponent { render() { const { status, collection } = this.props - const shouldRenderStatus = status && status !== SyncStatus.UNPUBLISHED && getCollectionType(collection) !== CollectionType.THIRD_PARTY + const shouldRenderStatus = status && status !== SyncStatus.UNPUBLISHED && !isThirdPartyCollection(collection) return shouldRenderStatus ? ( -
- {getCollectionType(collection) === CollectionType.THIRD_PARTY - ? t('collection_row.type_third_party') - : t('collection_row.type_standard')} -
+
{isThirdPartyCollection(collection) ? t('collection_row.type_third_party') : t('collection_row.type_standard')}
-
{getCollectionType(collection) === CollectionType.THIRD_PARTY ? '-' : }
+
{isThirdPartyCollection(collection) ? '-' : }
diff --git a/src/components/ItemEditorPage/LeftPanel/LeftPanel.tsx b/src/components/ItemEditorPage/LeftPanel/LeftPanel.tsx index df1294a05..4dee0a084 100644 --- a/src/components/ItemEditorPage/LeftPanel/LeftPanel.tsx +++ b/src/components/ItemEditorPage/LeftPanel/LeftPanel.tsx @@ -1,9 +1,9 @@ import * as React from 'react' import { Loader, Tabs } from 'decentraland-ui' import { t } from 'decentraland-dapps/dist/modules/translation/utils' -import { Collection, CollectionType } from 'modules/collection/types' +import { Collection } from 'modules/collection/types' import { CurationStatus } from 'modules/curations/types' -import { getCollectionType } from 'modules/collection/utils' +import { isThirdPartyCollection } from 'modules/collection/utils' import { Item, ItemType } from 'modules/item/types' import CollectionProvider from 'components/CollectionProvider' import Header from './Header' @@ -125,9 +125,7 @@ export default class LeftPanel extends React.PureComponent { getItems(collection: Collection | null, collectionItems: Item[]) { const { selectedCollectionId, orphanItems, isReviewing } = this.props if (selectedCollectionId && collection) { - return getCollectionType(collection) === CollectionType.THIRD_PARTY && isReviewing - ? collectionItems.filter(item => item.isPublished) - : collectionItems + return isThirdPartyCollection(collection) && isReviewing ? collectionItems.filter(item => item.isPublished) : collectionItems } return orphanItems } diff --git a/src/components/MappingEditor/MappingEditor.module.css b/src/components/MappingEditor/MappingEditor.module.css new file mode 100644 index 000000000..5cff41846 --- /dev/null +++ b/src/components/MappingEditor/MappingEditor.module.css @@ -0,0 +1,17 @@ +.main { + display: flex; + flex-direction: column; +} + +.mappings { + display: flex; + flex-direction: column; +} + +.main :global(.ui.dropdown > .text > img) { + margin-top: 3px; +} + +.mappingType :global(.ui.dropdown .menu > .item > img) { + margin-top: 0px; +} diff --git a/src/components/MappingEditor/MappingEditor.tsx b/src/components/MappingEditor/MappingEditor.tsx new file mode 100644 index 000000000..33a0f3244 --- /dev/null +++ b/src/components/MappingEditor/MappingEditor.tsx @@ -0,0 +1,158 @@ +import { SyntheticEvent, useCallback, useMemo } from 'react' +import { DropdownProps, Field, InputOnChangeData, SelectField, TextAreaField, TextAreaProps } from 'decentraland-ui' +import { MappingType, MultipleMapping } from '@dcl/schemas' +import { t } from 'decentraland-dapps/dist/modules/translation' +import allIcon from '../../icons/all.svg' +import multipleIcon from '../../icons/multiple.svg' +import singleIcon from '../../icons/single.svg' +import rangeIcon from '../../icons/range.svg' +import { Props } from './MappingEditor.types' +import styles from './MappingEditor.module.css' + +const mappingTypeIcons = { + [MappingType.ANY]: allIcon, + [MappingType.MULTIPLE]: multipleIcon, + [MappingType.SINGLE]: singleIcon, + [MappingType.RANGE]: rangeIcon +} + +export const MappingEditor = (props: Props) => { + const { mapping, error, disabled, onChange } = props + const [mappingType, mappingValue] = useMemo(() => { + switch (mapping.type) { + case MappingType.MULTIPLE: + return [MappingType.MULTIPLE, mapping.ids.join(', ')] + case MappingType.SINGLE: + return [MappingType.SINGLE, mapping.id] + case MappingType.RANGE: + return [MappingType.RANGE, `${mapping.from},${mapping.to}`] + case MappingType.ANY: + default: + return [MappingType.ANY, ''] + } + }, [mapping]) + + const mappingTypeOptions = useMemo( + () => + Object.values(MappingType).map((mapping: MappingType) => ({ + value: mapping, + image: mappingTypeIcons[mapping], + text: t(`mapping_editor.mapping_types.${mapping}`) + })), + [] + ) + + const handleMappingTypeChange = useCallback((_: SyntheticEvent, { value }: DropdownProps) => { + const mappingType = value as MappingType + switch (mappingType) { + case MappingType.ANY: + props.onChange({ type: mappingType }) + break + case MappingType.MULTIPLE: + props.onChange({ type: mappingType, ids: [] }) + break + case MappingType.SINGLE: + props.onChange({ type: mappingType, id: '' }) + break + case MappingType.RANGE: + props.onChange({ type: mappingType, to: '', from: '' }) + break + } + }, []) + + const handleSingleMappingValueChange = useCallback((_: React.ChangeEvent, data: InputOnChangeData) => { + onChange({ type: MappingType.SINGLE, id: data.value }) + }, []) + + const handleMultipleMappingValueChange = useCallback((_: React.ChangeEvent, data: TextAreaProps) => { + const ids = + data.value + ?.toString() + .replaceAll(/[^0-9,\s]/g, '') + .split(',') + .map(value => value.trim()) ?? [] + + onChange({ + type: MappingType.MULTIPLE, + ids + }) + }, []) + + const handleFromMappingValueChange = useCallback( + (_: React.ChangeEvent, data: InputOnChangeData) => { + onChange({ type: MappingType.RANGE, from: data.value, to: mappingValue.split(',')[1] }) + }, + [mappingValue] + ) + + const handleToMappingValueChange = useCallback( + (_: React.ChangeEvent, data: InputOnChangeData) => { + onChange({ type: MappingType.RANGE, from: mappingValue.split(',')[0], to: data.value }) + }, + [mappingValue] + ) + + return ( +
+ {' '} +
+ {mappingType === MappingType.ANY ? ( + + ) : mappingType === MappingType.SINGLE ? ( + + ) : mappingType === MappingType.MULTIPLE ? ( + + ) : mappingType === MappingType.RANGE ? ( + <> + + + + ) : null} +
+
+ ) +} diff --git a/src/components/MappingEditor/MappingEditor.types.ts b/src/components/MappingEditor/MappingEditor.types.ts new file mode 100644 index 000000000..e0d7efad4 --- /dev/null +++ b/src/components/MappingEditor/MappingEditor.types.ts @@ -0,0 +1,8 @@ +import { Mapping } from '@dcl/schemas' + +export type Props = { + mapping: Mapping + error?: string + disabled?: boolean + onChange: (mapping: Mapping) => void +} diff --git a/src/components/Modals/ApprovalFlowModal/ApprovalFlowModal.css b/src/components/Modals/ApprovalFlowModal/ApprovalFlowModal.css index 7bc587901..bdb886dbe 100644 --- a/src/components/Modals/ApprovalFlowModal/ApprovalFlowModal.css +++ b/src/components/Modals/ApprovalFlowModal/ApprovalFlowModal.css @@ -81,4 +81,5 @@ .ui.modal.ApprovalFlowModal .error-container .urn { font-size: 12px; + word-break: break-all; } diff --git a/src/components/Modals/CreateSingleItemModal/CreateSingleItemModal.css b/src/components/Modals/CreateSingleItemModal/CreateSingleItemModal.css index 18b1338c5..bd5cace92 100644 --- a/src/components/Modals/CreateSingleItemModal/CreateSingleItemModal.css +++ b/src/components/Modals/CreateSingleItemModal/CreateSingleItemModal.css @@ -36,13 +36,15 @@ } .CreateSingleItemModal .preview { + display: flex; + flex-direction: column; padding: 24px; } .CreateSingleItemModal .preview .thumbnail-container { position: relative; - width: 100%; margin-bottom: 20px; + width: 150px; } .CreateSingleItemModal .smart-wearable .preview .thumbnail-container { @@ -143,6 +145,7 @@ .CreateSingleItemModal .data { padding-top: 24px; + padding-bottom: 24px; } .CreateSingleItemModal .smart-wearable.data { diff --git a/src/components/Modals/CreateSingleItemModal/CreateSingleItemModal.tsx b/src/components/Modals/CreateSingleItemModal/CreateSingleItemModal.tsx index 3194dd173..d083765c0 100644 --- a/src/components/Modals/CreateSingleItemModal/CreateSingleItemModal.tsx +++ b/src/components/Modals/CreateSingleItemModal/CreateSingleItemModal.tsx @@ -1,7 +1,17 @@ import * as React from 'react' import uuid from 'uuid' import { ethers } from 'ethers' -import { BodyPartCategory, BodyShape, EmoteCategory, EmoteDataADR74, Rarity, PreviewProjection, WearableCategory } from '@dcl/schemas' +import { + BodyPartCategory, + BodyShape, + EmoteCategory, + EmoteDataADR74, + Rarity, + PreviewProjection, + WearableCategory, + Mapping, + MappingType +} from '@dcl/schemas' import { MAX_EMOTE_FILE_SIZE, MAX_SKIN_FILE_SIZE, @@ -66,7 +76,8 @@ import { decodeURN, isThirdParty, isThirdPartyCollectionDecodedUrn, - isThirdPartyV2CollectionDecodedUrn + isThirdPartyV2CollectionDecodedUrn, + URNType } from 'lib/urn' import ItemDropdown from 'components/ItemDropdown' import Icon from 'components/Icon' @@ -76,6 +87,7 @@ import ItemProperties from 'components/ItemProperties' import { calculateFileSize, calculateModelFinalSize } from 'modules/item/export' import { MAX_THUMBNAIL_SIZE } from 'modules/assetPack/utils' import { Authorization } from 'lib/api/auth' +import { MappingEditor } from 'components/MappingEditor/MappingEditor' import { BUILDER_SERVER_URL, BuilderAPI } from 'lib/api/builder' import EditPriceAndBeneficiaryModal from '../EditPriceAndBeneficiaryModal' import ImportStep from './ImportStep/ImportStep' @@ -128,6 +140,7 @@ export default class CreateSingleItemModal extends React.PureComponent 0 ? item.mappings[0] : { type: MappingType.ANY } if (addRepresentation) { const missingBodyShape = getMissingBodyShapeType(item) @@ -246,7 +259,8 @@ export default class CreateSingleItemModal extends React.PureComponent { + switch (mapping.type) { + case MappingType.SINGLE: + return !!mapping.id + case MappingType.MULTIPLE: + return mapping.ids.length > 0 + case MappingType.RANGE: + return !!mapping.from && !!mapping.to && BigInt(mapping.from) <= BigInt(mapping.to) + } + } + + handleMappingChange = (mapping: Mapping) => { + this.setState({ mapping }) + } + handleOpenDocs = () => window.open('https://docs.decentraland.org/3d-modeling/3d-models/', '_blank') handleNameChange = (_event: React.ChangeEvent, props: InputOnChangeData) => @@ -848,8 +878,10 @@ export default class CreateSingleItemModal extends React.PureComponent ({ value, text: t(`${type!}.category.${value}`) }))} onChange={this.handleCategoryChange} /> + {belongsToAThirdPartyV2Collection ? ( + + ) : null} ) } @@ -936,9 +971,10 @@ export default class CreateSingleItemModal extends React.PureComponent prop !== undefined) + if ((belongsToAThirdPartyV2Collection && !mapping) || (belongsToAThirdPartyV2Collection && mapping && !this.isMappingValid(mapping))) { + return false + } + if (isRequirementMet && isEmote && modelSize && modelSize > MAX_EMOTE_FILE_SIZE) { this.setState({ error: t('create_single_item_modal.error.item_too_big', { @@ -1020,7 +1060,7 @@ export default class CreateSingleItemModal extends React.PureComponent - +
{title} {isRepresentation ? null : ( @@ -1031,7 +1071,7 @@ export default class CreateSingleItemModal extends React.PureComponent {this.renderMetrics()} - +
{isAddingRepresentation ? null : (
diff --git a/src/components/Modals/CreateSingleItemModal/CreateSingleItemModal.types.ts b/src/components/Modals/CreateSingleItemModal/CreateSingleItemModal.types.ts index 9e95f37ab..9e0239f88 100644 --- a/src/components/Modals/CreateSingleItemModal/CreateSingleItemModal.types.ts +++ b/src/components/Modals/CreateSingleItemModal/CreateSingleItemModal.types.ts @@ -1,6 +1,6 @@ import { Dispatch } from 'redux' import { ModalProps } from 'decentraland-dapps/dist/providers/ModalProvider/ModalProvider.types' -import { IPreviewController, Rarity } from '@dcl/schemas' +import { IPreviewController, Mapping, Rarity } from '@dcl/schemas' import { Metrics } from 'modules/models/types' import { Collection } from 'modules/collection/types' import { saveItemRequest, SaveItemRequestAction } from 'modules/item/actions' @@ -52,6 +52,7 @@ export type StateData = { requiredPermissions?: string[] tags?: string[] modelSize?: number + mapping: Mapping blockVrmExport?: boolean } export type State = { diff --git a/src/components/Modals/EditCollectionNameModal/EditCollectionNameModal.tsx b/src/components/Modals/EditCollectionNameModal/EditCollectionNameModal.tsx index be5a1e20e..e58093f60 100644 --- a/src/components/Modals/EditCollectionNameModal/EditCollectionNameModal.tsx +++ b/src/components/Modals/EditCollectionNameModal/EditCollectionNameModal.tsx @@ -2,9 +2,9 @@ import * as React from 'react' import { ModalNavigation, ModalContent, ModalActions, Button, Field, InputOnChangeData, Form } from 'decentraland-ui' import Modal from 'decentraland-dapps/dist/containers/Modal' import { t } from 'decentraland-dapps/dist/modules/translation/utils' -import { CollectionType, COLLECTION_NAME_MAX_LENGTH, TP_COLLECTION_NAME_MAX_LENGTH } from 'modules/collection/types' +import { isThirdPartyCollection } from 'modules/collection/utils' +import { COLLECTION_NAME_MAX_LENGTH, TP_COLLECTION_NAME_MAX_LENGTH } from 'modules/collection/types' import { Props, State, EditCollectionNameModalMetadata } from './EditCollectionNameModal.types' -import { getCollectionType } from 'modules/collection/utils' export default class EditCollectionNameModal extends React.PureComponent { state: State = { @@ -12,10 +12,9 @@ export default class EditCollectionNameModal extends React.PureComponent, { value }: InputOnChangeData) => { - const nameMaxLength = - getCollectionType(this.props.metadata.collection) === CollectionType.THIRD_PARTY - ? TP_COLLECTION_NAME_MAX_LENGTH - : COLLECTION_NAME_MAX_LENGTH + const nameMaxLength = isThirdPartyCollection(this.props.metadata.collection) + ? TP_COLLECTION_NAME_MAX_LENGTH + : COLLECTION_NAME_MAX_LENGTH this.setState({ name: value.slice(0, nameMaxLength) }) } diff --git a/src/components/ThirdPartyCollectionDetailPage/ThirdPartyCollectionDetailPage.container.ts b/src/components/ThirdPartyCollectionDetailPage/ThirdPartyCollectionDetailPage.container.ts index 481381d3b..78485316a 100644 --- a/src/components/ThirdPartyCollectionDetailPage/ThirdPartyCollectionDetailPage.container.ts +++ b/src/components/ThirdPartyCollectionDetailPage/ThirdPartyCollectionDetailPage.container.ts @@ -11,11 +11,10 @@ import { FETCH_COLLECTION_ITEMS_REQUEST } from 'modules/item/actions' import { FETCH_COLLECTIONS_REQUEST, DELETE_COLLECTION_REQUEST } from 'modules/collection/actions' import { openModal } from 'decentraland-dapps/dist/modules/modal/actions' import { getCollectionThirdParty, isFetchingAvailableSlots } from 'modules/thirdParty/selectors' +import { fetchThirdPartyAvailableSlotsRequest } from 'modules/thirdParty/actions' +import { isThirdPartyCollection } from 'modules/collection/utils' import { MapStateProps, MapDispatchProps, MapDispatch } from './ThirdPartyCollectionDetailPage.types' import CollectionDetailPage from './ThirdPartyCollectionDetailPage' -import { fetchThirdPartyAvailableSlotsRequest } from 'modules/thirdParty/actions' -import { getCollectionType } from 'modules/collection/utils' -import { CollectionType } from 'modules/collection/types' const mapState = (state: RootState): MapStateProps => { const collectionId = getCollectionId(state) || '' @@ -31,8 +30,7 @@ const mapState = (state: RootState): MapStateProps => { paginatedData, wallet: getWallet(state)!, collection, - thirdParty: - collection && getCollectionType(collection) === CollectionType.THIRD_PARTY ? getCollectionThirdParty(state, collection) : null, + thirdParty: collection && isThirdPartyCollection(collection) ? getCollectionThirdParty(state, collection) : null, authorizations: getAuthorizations(state), isLoading: isLoadingType(getLoadingCollection(state), FETCH_COLLECTIONS_REQUEST) || diff --git a/src/icons/all.svg b/src/icons/all.svg new file mode 100644 index 000000000..aef07c6c4 --- /dev/null +++ b/src/icons/all.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/icons/multiple.svg b/src/icons/multiple.svg new file mode 100644 index 000000000..ce5a72609 --- /dev/null +++ b/src/icons/multiple.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/icons/range.svg b/src/icons/range.svg new file mode 100644 index 000000000..b4ebd3447 --- /dev/null +++ b/src/icons/range.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/icons/single.svg b/src/icons/single.svg new file mode 100644 index 000000000..6823ff48d --- /dev/null +++ b/src/icons/single.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/lib/api/builder.ts b/src/lib/api/builder.ts index 10cb04678..5baa34821 100644 --- a/src/lib/api/builder.ts +++ b/src/lib/api/builder.ts @@ -1,5 +1,5 @@ import { AxiosRequestConfig, AxiosError } from 'axios' -import { Entity, Rarity } from '@dcl/schemas' +import { Entity, Mapping, Rarity } from '@dcl/schemas' import { BaseAPI, APIParam, RetryParams } from 'decentraland-dapps/dist/lib/api' import { Omit } from 'decentraland-dapps/dist/lib/types' import { config } from 'config' @@ -76,6 +76,7 @@ export type RemoteItem = { created_at: Date updated_at: Date utility: string | null + mappings: Mapping[] | null local_content_hash: string | null catalyst_content_hash: string | null } @@ -331,8 +332,10 @@ function fromPoolGroup(poolGroup: RemotePoolGroup): PoolGroup { } } -function toRemoteItem(item: Item): Omit { - const remoteItem: Omit = { +function toRemoteItem( + item: Item +): Omit { + return { id: item.id, name: item.name, description: item.description || '', @@ -348,18 +351,14 @@ function toRemoteItem(item: Item): Omit total_supply: item.totalSupply === undefined ? null : item.totalSupply, is_published: false, is_approved: false, - in_catalyst: item.inCatalyst || false, utility: item.utility || null, + mappings: item.mappings || null, type: item.type, data: item.data, metrics: item.metrics, contents: item.contents, - content_hash: item.blockchainContentHash, - local_content_hash: item.currentContentHash, - catalyst_content_hash: item.catalystContentHash + content_hash: item.blockchainContentHash } - - return remoteItem } function fromRemoteItem(remoteItem: RemoteItem) { @@ -379,6 +378,7 @@ function fromRemoteItem(remoteItem: RemoteItem) { blockchainContentHash: remoteItem.content_hash, catalystContentHash: remoteItem.catalyst_content_hash, metrics: remoteItem.metrics, + mappings: remoteItem.mappings, createdAt: +new Date(remoteItem.created_at), updatedAt: +new Date(remoteItem.created_at) } @@ -397,8 +397,8 @@ function fromRemoteItem(remoteItem: RemoteItem) { return item } -function toRemoteCollection(collection: Collection): Omit { - const remoteCollection: Omit = { +function toRemoteCollection(collection: Collection): Omit { + return { id: collection.id, name: collection.name, eth_address: collection.owner, @@ -410,11 +410,8 @@ function toRemoteCollection(collection: Collection): Omit { is_approved: true, in_catalyst: true, created_at: now, - updated_at: now + updated_at: now, + mappings: null } item = { @@ -98,7 +99,8 @@ describe('when converting a RemoteItem into an Item', () => { isApproved: true, inCatalyst: true, createdAt: now, - updatedAt: now + updatedAt: now, + mappings: null } }) diff --git a/src/lib/api/transformations.ts b/src/lib/api/transformations.ts index 748ed1baa..b1b070721 100644 --- a/src/lib/api/transformations.ts +++ b/src/lib/api/transformations.ts @@ -19,7 +19,8 @@ export function fromRemoteItem(remoteItem: RemoteItem): Item { catalystContentHash: remoteItem.catalyst_content_hash, metrics: remoteItem.metrics, createdAt: +new Date(remoteItem.created_at), - updatedAt: +new Date(remoteItem.created_at) + updatedAt: +new Date(remoteItem.created_at), + mappings: remoteItem.mappings } if (remoteItem.collection_id) item.collectionId = remoteItem.collection_id diff --git a/src/lib/urn.ts b/src/lib/urn.ts index 01de63a12..bd109da06 100644 --- a/src/lib/urn.ts +++ b/src/lib/urn.ts @@ -169,12 +169,16 @@ export function extractThirdPartyTokenId(urn: URN) { // TODO: This logic is repeated in collection/util's `getCollectionType`, but being used only for items (item.urn). // It should probably be replaced by a getItemType or we should see if it's better to only keep one way of doing this -export function isThirdParty(urn?: string) { +export function isThirdParty(urn?: string, version?: URNType.COLLECTIONS_THIRDPARTY | URNType.COLLECTIONS_THIRDPARTY_V2) { if (!urn) { return false } const decodedURN = decodeURN(urn) + if (version) { + return decodedURN.type === version + } + return decodedURN.type === URNType.COLLECTIONS_THIRDPARTY || decodedURN.type === URNType.COLLECTIONS_THIRDPARTY_V2 } diff --git a/src/modules/collection/selectors.ts b/src/modules/collection/selectors.ts index 83838a154..3ad812d6c 100644 --- a/src/modules/collection/selectors.ts +++ b/src/modules/collection/selectors.ts @@ -81,7 +81,8 @@ export const getAuthorizedCollections = createSelector< switch (type) { case CollectionType.STANDARD: return address && canSeeCollection(collection, address) - case CollectionType.THIRD_PARTY: { + case CollectionType.THIRD_PARTY: + case CollectionType.THIRD_PARTY_V2: { const thirdParty = getThirdPartyForCollection(thirdParties, collection) return address && thirdParty && isUserManagerOfThirdParty(address, thirdParty) } diff --git a/src/modules/collection/types.ts b/src/modules/collection/types.ts index f3a36f9dc..cad38b019 100644 --- a/src/modules/collection/types.ts +++ b/src/modules/collection/types.ts @@ -23,7 +23,8 @@ export type Collection = { export enum CollectionType { STANDARD = 'standard', - THIRD_PARTY = 'third_party' + THIRD_PARTY = 'third_party', + THIRD_PARTY_V2 = 'third_party_v2' } export enum RoleType { diff --git a/src/modules/collection/utils.spec.ts b/src/modules/collection/utils.spec.ts index f6561df37..524fdc77b 100644 --- a/src/modules/collection/utils.spec.ts +++ b/src/modules/collection/utils.spec.ts @@ -121,7 +121,7 @@ describe('when getting the collection type', () => { collection = { id: 'aCollection', urn: BodyShape.FEMALE.toString() } as Collection }) - it('should return false', () => { + it('should return a standard collection type', () => { expect(getCollectionType(collection)).toBe(CollectionType.STANDARD) }) }) @@ -132,7 +132,7 @@ describe('when getting the collection type', () => { collection = { id: 'aCollection', urn: buildCatalystItemURN('0xc6d2000a7a1ddca92941f4e2b41360fe4ee2abd8', '22') } as Collection }) - it('should return false', () => { + it('should return a standard collection type', () => { expect(getCollectionType(collection)).toBe(CollectionType.STANDARD) }) }) @@ -143,7 +143,7 @@ describe('when getting the collection type', () => { collection = { id: 'aCollection', urn: buildThirdPartyURN('thirdpartyname', 'collection-id', '22') } as Collection }) - it('should return true', () => { + it('should return a third party collection type', () => { expect(getCollectionType(collection)).toBe(CollectionType.THIRD_PARTY) }) }) diff --git a/src/modules/collection/utils.ts b/src/modules/collection/utils.ts index 614e43670..7063711e4 100644 --- a/src/modules/collection/utils.ts +++ b/src/modules/collection/utils.ts @@ -79,13 +79,19 @@ export function getCollectionBaseURI() { return config.get('ERC721_COLLECTION_BASE_URI', '') } +export function isThirdPartyCollection(collection: Collection) { + const collectionType = getCollectionType(collection) + return collectionType === CollectionType.THIRD_PARTY || collectionType === CollectionType.THIRD_PARTY_V2 +} + export function getCollectionType(collection: Collection): CollectionType { const { type } = decodeURN(collection.urn) switch (type) { case URNType.COLLECTIONS_THIRDPARTY: - case URNType.COLLECTIONS_THIRDPARTY_V2: return CollectionType.THIRD_PARTY + case URNType.COLLECTIONS_THIRDPARTY_V2: + return CollectionType.THIRD_PARTY_V2 case URNType.COLLECTIONS_V2: case URNType.BASE_AVATARS: return CollectionType.STANDARD diff --git a/src/modules/item/export.ts b/src/modules/item/export.ts index 8cddc54e2..46ecac794 100644 --- a/src/modules/item/export.ts +++ b/src/modules/item/export.ts @@ -3,7 +3,7 @@ import { DeploymentPreparationData, buildEntity } from 'dcl-catalyst-client/dist import { MerkleDistributorInfo } from '@dcl/content-hash-tree/dist/types' import { calculateMultipleHashesADR32, calculateMultipleHashesADR32LegacyQmHash } from '@dcl/hashing' import { BuilderAPI } from 'lib/api/builder' -import { buildCatalystItemURN } from 'lib/urn' +import { buildCatalystItemURN, decodeURN, isThirdPartyV2CollectionDecodedUrn } from 'lib/urn' import { makeContentFiles, computeHashes } from 'modules/deployment/contentUtils' import { Collection } from 'modules/collection/types' import { Item, IMAGE_PATH, THUMBNAIL_PATH, ItemType, EntityHashingType, isEmoteItemType, VIDEO_PATH } from './types' @@ -131,6 +131,8 @@ function buildTPItemEntityMetadata(item: Item, itemHash: string, tree: MerkleDis throw new Error('Item does not have URN') } + const decodedURN = decodeURN(item.urn) + // The order of the metadata properties can't be changed. Changing it will result in a different content hash. const baseEntityData = { id: item.urn, @@ -149,7 +151,8 @@ function buildTPItemEntityMetadata(item: Item, itemHash: string, tree: MerkleDis image: IMAGE_PATH, thumbnail: THUMBNAIL_PATH, metrics: item.metrics, - content: item.contents + content: item.contents, + ...(isThirdPartyV2CollectionDecodedUrn(decodedURN) && item.mappings ? { mappings: item.mappings } : {}) } return { diff --git a/src/modules/item/types.ts b/src/modules/item/types.ts index 1c21f7471..8b21de205 100644 --- a/src/modules/item/types.ts +++ b/src/modules/item/types.ts @@ -1,5 +1,5 @@ import { BuiltItem, Content } from '@dcl/builder-client' -import { BodyShape, EmoteDataADR74, Wearable, WearableCategory, Rarity, HideableWearableCategory } from '@dcl/schemas' +import { BodyShape, EmoteDataADR74, Wearable, WearableCategory, Rarity, HideableWearableCategory, Mapping } from '@dcl/schemas' import { AnimationMetrics, ModelMetrics } from 'modules/models/types' import { Cheque } from 'modules/thirdParty/types' @@ -112,6 +112,7 @@ export type Item = Omit & { catalystContentHash: string | null data: T extends ItemType.WEARABLE ? WearableData : EmoteDataADR74 metrics: T extends ItemType.WEARABLE ? ModelMetrics : AnimationMetrics + mappings: Mapping[] | null } export const isEmoteItemType = (item: Item | Item): item is Item => diff --git a/src/modules/location/selectors.ts b/src/modules/location/selectors.ts index 623f49c3d..8abe15bbf 100644 --- a/src/modules/location/selectors.ts +++ b/src/modules/location/selectors.ts @@ -4,8 +4,7 @@ import { RootState } from 'modules/common/types' import { getCollection } from 'modules/collection/selectors' import { getPaginatedCollectionItems } from 'modules/item/selectors' import { getFirstWearableOrItem } from 'modules/item/utils' -import { getCollectionType } from 'modules/collection/utils' -import { CollectionType } from 'modules/collection/types' +import { isThirdPartyCollection } from 'modules/collection/utils' const landIdMatchSelector = createMatchSelector< RootState, @@ -76,7 +75,7 @@ export const getSelectedItemId = (state: RootState) => { const collection = getCollection(state, collectionId) - const isReviewingTPCollection = collection ? getCollectionType(collection) === CollectionType.THIRD_PARTY && isReviewing(state) : false + const isReviewingTPCollection = collection ? isThirdPartyCollection(collection) && isReviewing(state) : false const allItems = getPaginatedCollectionItems(state, collectionId) const items = isReviewingTPCollection ? allItems.filter(item => item.isPublished) : allItems return getFirstWearableOrItem(items)?.id ?? null diff --git a/src/modules/translation/languages/en.json b/src/modules/translation/languages/en.json index 9646d8d10..f5498f451 100644 --- a/src/modules/translation/languages/en.json +++ b/src/modules/translation/languages/en.json @@ -272,6 +272,22 @@ "mana_not_allowed": "You need to authorize enough MANA to claim a new name", "not_enough_mana": "You don't have enough MANA to claim a new name" }, + "mapping_editor": { + "mapping_types": { + "single": "Single", + "multiple": "Multiple", + "any": "Any", + "range": "Range" + }, + "mapping_type_label": "NFTs token link type", + "mapping_value_label": "Linked to", + "mapping_value_any": "Any token ID", + "mapping_value_from_label": "From", + "mapping_value_to_label": "To", + "mapping_value_multiple_info": "Token IDs separated by a comma", + "mapping_value_multiple_amount_info": "{count} linked token {count, plural, one {ID} other {IDs}}", + "mapping_type_info": "Links this Wearable to one or multiple token IDs" + }, "create_single_item_modal": { "title": "New Item", "thumbnail_step_title": "Edit Thumbnail", @@ -1697,7 +1713,8 @@ "collection": { "type": { "standard": "Collection", - "third_party": "Linked Wearables Collection" + "third_party": "Linked Wearables Collection", + "third_party_v2": "Linked Wearables Collection" } }, "item": { diff --git a/src/modules/translation/languages/es.json b/src/modules/translation/languages/es.json index 4a95ceaf3..379ee3b84 100644 --- a/src/modules/translation/languages/es.json +++ b/src/modules/translation/languages/es.json @@ -274,6 +274,22 @@ "mana_not_allowed": "Debes autorizar suficiente MANA para reclamar un nuevo nombre", "not_enough_mana": "No tienes suficiente MANA para reclamar un nuevo nombre" }, + "mapping_editor": { + "mapping_types": { + "single": "Único", + "multiple": "Múltiple", + "any": "Cualquiera", + "range": "Rango" + }, + "mapping_type_label": "Tipo de vinculación con tokens NFT", + "mapping_value_label": "Vinculado a", + "mapping_value_any": "Todos los token IDs", + "mapping_value_from_label": "Desde", + "mapping_value_to_label": "Hasta", + "mapping_value_multiple_info": "IDs de tokens separados por coma", + "mapping_value_multiple_amount_info": "{count} {count, plural, one {ID} other {IDs}} de tokens vinculados", + "mapping_type_info": "Vincula éste Wearable a uno o múltiples IDs de token" + }, "create_single_item_modal": { "title": "Nuevo item", "thumbnail_step_title": "Editar el thumbnail", @@ -1707,7 +1723,8 @@ "collection": { "type": { "standard": "Colección", - "third_party": "Colección Externa" + "third_party": "Colección Externa", + "third_party_v2": "Colección Externa" } }, "item": { diff --git a/src/modules/translation/languages/zh.json b/src/modules/translation/languages/zh.json index 0a199d2eb..6eb0a6ce9 100644 --- a/src/modules/translation/languages/zh.json +++ b/src/modules/translation/languages/zh.json @@ -268,6 +268,22 @@ "mana_not_allowed": "您需要授权足够的MANA才能申请新名称", "not_enough_mana": "您没有足够的MANA来申请新名称" }, + "mapping_editor": { + "mapping_types": { + "single": "单身的", + "multiple": "多", + "any": "任何", + "range": "音域" + }, + "mapping_type_label": "NFTs 代币链接类型", + "mapping_value_label": "已链接至", + "mapping_value_any": "任何代币 ID", + "mapping_value_from_label": "从", + "mapping_value_to_label": "至", + "mapping_value_multiple_info": "以逗号分隔的令牌 ID", + "mapping_value_multiple_amount_info": "{count} 个链接令牌 {count, plural, one {ID} other {IDs}}", + "mapping_type_info": "将此可穿戴物品链接到一个或多个令牌 ID" + }, "create_single_item_modal": { "title": "新物品", "thumbnail_step_title": "编辑缩略图", @@ -1686,7 +1702,8 @@ "collection": { "type": { "standard": "采集", - "third_party": "链接的可穿戴设备系列" + "third_party": "链接的可穿戴设备系列", + "third_party_v2": "链接的可穿戴设备系列" } }, "item": { diff --git a/src/routing/locations.ts b/src/routing/locations.ts index c366b6f0e..3d2ceac9f 100644 --- a/src/routing/locations.ts +++ b/src/routing/locations.ts @@ -37,6 +37,7 @@ export const locations = { case CollectionType.STANDARD: return injectParams(`/collections/${collectionId}`, { tab: 'tab' }, options) case CollectionType.THIRD_PARTY: + case CollectionType.THIRD_PARTY_V2: return injectParams(locations.thirdPartyCollectionDetail(collectionId), { tab: 'tab' }, options) default: throw new Error(`Invalid collection type ${type as unknown as string}`) diff --git a/src/specs/item.ts b/src/specs/item.ts index 3ccd44f87..f68f69745 100644 --- a/src/specs/item.ts +++ b/src/specs/item.ts @@ -41,7 +41,8 @@ export const mockedItem: Item = { replaces: [WearableCategory.HELMET], hides: [WearableCategory.HAIR], tags: ['aHat'] - } + }, + mappings: null } export const mockedLocalItem: LocalItem = { @@ -77,7 +78,8 @@ export const mockedLocalItem: LocalItem = { replaces: [LocalItemWearableCategory.HELMET], hides: [LocalItemWearableCategory.HAIR], tags: ['aHat'] - } + }, + mappings: null } export const mockedRemoteItem: RemoteItem = { @@ -125,7 +127,8 @@ export const mockedRemoteItem: RemoteItem = { created_at: 0, updated_at: 0, local_content_hash: 'someHash', - catalyst_content_hash: null + catalyst_content_hash: null, + mappings: null } export const mockedItemContents = { 'anItemContent.glb': new Blob(), 'thumbnail.png': new Blob() }