From f1266d7f3949e026353d6ecbed4d6f9b54d8fe5f Mon Sep 17 00:00:00 2001 From: Melisa Anabella Rossi Date: Mon, 20 May 2024 18:07:32 -0300 Subject: [PATCH] feat: add contribute tab (#3102) --- package-lock.json | 8 +- package.json | 2 +- .../NameTabs/NameTabs.container.ts | 10 +- .../WorldListPage/NameTabs/NameTabs.spec.tsx | 80 ++++++-- .../WorldListPage/NameTabs/NameTabs.tsx | 9 +- .../WorldListPage/NameTabs/NameTabs.types.ts | 2 + .../PublishSceneButton.spec.tsx | 109 +++++++++++ .../PublishSceneButton/PublishSceneButton.tsx | 47 +++++ .../PublishSceneButton.types.ts | 12 ++ .../WorldListPage/PublishSceneButton/index.ts | 3 + .../WorldContributorTab.container.ts | 28 +++ .../WorldContributorTab.spec.tsx | 178 ++++++++++++++++++ .../WorldContributorTab.tsx | 101 ++++++++++ .../WorldContributorTab.types.ts | 20 ++ .../WorldContributorTab/index.ts | 3 + .../WorldListPage/WorldListPage.container.ts | 13 +- .../WorldListPage/WorldListPage.css | 16 -- .../WorldListPage/WorldListPage.tsx | 148 ++++----------- .../WorldListPage/WorldListPage.types.ts | 12 +- .../WorldUrl/WorldUrl.module.css | 15 ++ .../WorldListPage/WorldUrl/WorldUrl.spec.tsx | 39 ++++ .../WorldListPage/WorldUrl/WorldUrl.tsx | 26 +++ .../WorldListPage/WorldUrl/WorldUrl.types.ts | 7 + .../WorldListPage/WorldUrl/index.ts | 3 + src/components/WorldListPage/hooks.ts | 6 +- .../WorldListPage/{utils.ts => utils.tsx} | 21 +++ src/lib/api/ens.ts | 48 ++++- src/lib/api/marketplace.ts | 50 +++++ src/lib/api/worlds.spec.ts | 26 +++ src/lib/api/worlds.ts | 21 +++ src/modules/analytics/sagas.ts | 2 +- src/modules/ens/actions.ts | 12 ++ src/modules/ens/reducer.spec.ts | 87 +++++++++ src/modules/ens/reducer.ts | 47 ++++- src/modules/ens/sagas.spec.ts | 114 ++++++++++- src/modules/ens/sagas.ts | 55 +++++- src/modules/ens/selectors.spec.ts | 72 ++++++- src/modules/ens/selectors.ts | 6 + src/modules/ens/types.ts | 9 + src/modules/features/selectors.spec.ts | 6 +- src/modules/features/selectors.ts | 8 + src/modules/features/types.ts | 3 +- src/modules/translation/languages/en.json | 17 +- src/modules/translation/languages/es.json | 17 +- src/modules/translation/languages/zh.json | 17 +- 45 files changed, 1359 insertions(+), 176 deletions(-) create mode 100644 src/components/WorldListPage/PublishSceneButton/PublishSceneButton.spec.tsx create mode 100644 src/components/WorldListPage/PublishSceneButton/PublishSceneButton.tsx create mode 100644 src/components/WorldListPage/PublishSceneButton/PublishSceneButton.types.ts create mode 100644 src/components/WorldListPage/PublishSceneButton/index.ts create mode 100644 src/components/WorldListPage/WorldContributorTab/WorldContributorTab.container.ts create mode 100644 src/components/WorldListPage/WorldContributorTab/WorldContributorTab.spec.tsx create mode 100644 src/components/WorldListPage/WorldContributorTab/WorldContributorTab.tsx create mode 100644 src/components/WorldListPage/WorldContributorTab/WorldContributorTab.types.ts create mode 100644 src/components/WorldListPage/WorldContributorTab/index.ts create mode 100644 src/components/WorldListPage/WorldUrl/WorldUrl.module.css create mode 100644 src/components/WorldListPage/WorldUrl/WorldUrl.spec.tsx create mode 100644 src/components/WorldListPage/WorldUrl/WorldUrl.tsx create mode 100644 src/components/WorldListPage/WorldUrl/WorldUrl.types.ts create mode 100644 src/components/WorldListPage/WorldUrl/index.ts rename src/components/WorldListPage/{utils.ts => utils.tsx} (63%) diff --git a/package-lock.json b/package-lock.json index d26b0c32a..c7cbe4862 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48,7 +48,7 @@ "decentraland-ecs": "6.12.4-7784644013.commit-f770b3e", "decentraland-experiments": "^1.0.2", "decentraland-transactions": "^2.6.1", - "decentraland-ui": "^5.23.2", + "decentraland-ui": "^5.23.3", "ethers": "^5.6.8", "file-saver": "^2.0.1", "graphql": "^15.8.0", @@ -11478,9 +11478,9 @@ } }, "node_modules/decentraland-ui": { - "version": "5.23.2", - "resolved": "https://registry.npmjs.org/decentraland-ui/-/decentraland-ui-5.23.2.tgz", - "integrity": "sha512-ublbKOR3OTG1ZouXo3lVnTq6LNAg1oKmdXZKBDuG9SjyGw/RJfzLWFlPtEiObKvXBTYGirhokOa32V2PvX8mzg==", + "version": "5.23.3", + "resolved": "https://registry.npmjs.org/decentraland-ui/-/decentraland-ui-5.23.3.tgz", + "integrity": "sha512-k/+gt+MRPb2CJ/9xymkNX8orxrK48G2kztoxfyL3wqeRIh/s99FwKA806S0k8i7LM96tuv+wvl4CJ9XJPU3xPg==", "dependencies": { "@dcl/schemas": "^11.9.0", "@dcl/ui-env": "^1.4.0", diff --git a/package.json b/package.json index 7b2f21952..4ebf9a2bc 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "decentraland-ecs": "6.12.4-7784644013.commit-f770b3e", "decentraland-experiments": "^1.0.2", "decentraland-transactions": "^2.6.1", - "decentraland-ui": "^5.23.2", + "decentraland-ui": "^5.23.3", "ethers": "^5.6.8", "file-saver": "^2.0.1", "graphql": "^15.8.0", diff --git a/src/components/WorldListPage/NameTabs/NameTabs.container.ts b/src/components/WorldListPage/NameTabs/NameTabs.container.ts index 6cb0b263e..f4d652494 100644 --- a/src/components/WorldListPage/NameTabs/NameTabs.container.ts +++ b/src/components/WorldListPage/NameTabs/NameTabs.container.ts @@ -1,12 +1,20 @@ import { connect } from 'react-redux' import { push } from 'connected-react-router' +import { getIsWorldContributorEnabled } from 'modules/features/selectors' +import { RootState } from 'modules/common/types' import NameTabs from './NameTabs' import { MapDispatch, MapDispatchProps } from './NameTabs.types' +const mapState = (state: RootState) => { + return { + isWorldContributorEnabled: getIsWorldContributorEnabled(state) + } +} + const mapDispatch = (dispatch: MapDispatch): MapDispatchProps => { return { onNavigate: to => dispatch(push(to)) } } -export default connect(null, mapDispatch)(NameTabs) +export default connect(mapState, mapDispatch)(NameTabs) diff --git a/src/components/WorldListPage/NameTabs/NameTabs.spec.tsx b/src/components/WorldListPage/NameTabs/NameTabs.spec.tsx index c4107dac9..bc132d106 100644 --- a/src/components/WorldListPage/NameTabs/NameTabs.spec.tsx +++ b/src/components/WorldListPage/NameTabs/NameTabs.spec.tsx @@ -11,6 +11,10 @@ const mockUseCurrentlySelectedTab = useCurrentlySelectedTab as jest.Mock const mockMobile = Mobile as jest.Mock const mockNotMobile = NotMobile as jest.Mock +const dclTabText = 'Decentraland NAMEs' +const ensTabText = 'ENS Domains' +const contributorTabText = 'Contributor' + describe('when rendering the name tabs component', () => { let useCurrentlySelectedTabResult: UseCurrentlySelectedTabResult let onNavigate: jest.Mock @@ -33,7 +37,7 @@ describe('when rendering the name tabs component', () => { }) it('should call the on navigate prop with the pathname, url search params and an added tab query param with dcl as value', () => { - render() + render() expect(onNavigate).toHaveBeenCalledWith( `${useCurrentlySelectedTabResult.pathname}?${useCurrentlySelectedTabResult.urlSearchParams.toString()}&${TAB_QUERY_PARAM_KEY}=${ TabType.DCL @@ -42,24 +46,20 @@ describe('when rendering the name tabs component', () => { }) }) - describe('when the tab param is returned as dcl or ens bu the currently selected tab hook', () => { - let dclTabText: string - let ensTabText: string - + describe('when the tab param is returned as dcl, ens or contributor by the currently selected tab hook', () => { beforeEach(() => { useCurrentlySelectedTabResult.tab = TabType.DCL - dclTabText = 'Decentraland NAMEs' - ensTabText = 'ENS Domains' mockMobile.mockImplementation(({ children }) => children as ReactNode) mockNotMobile.mockImplementation(() => null) }) - it('should render both the ens and dcl tabs name tabs', () => { - render() + it('should render both the ens, dcl and contributor tabs name tabs', () => { + render() expect(screen.getByText(dclTabText)).toBeInTheDocument() expect(screen.getByText(ensTabText)).toBeInTheDocument() + expect(screen.getByText(contributorTabText)).toBeInTheDocument() }) describe('when the tab param is ens', () => { @@ -68,9 +68,10 @@ describe('when rendering the name tabs component', () => { }) it('should add the active css class to the ens tab', () => { - render() + render() expect(screen.getByText(ensTabText)).toHaveClass('active') + expect(screen.getByText(contributorTabText)).not.toHaveClass('active') expect(screen.getByText(dclTabText)).not.toHaveClass('active') }) }) @@ -81,16 +82,31 @@ describe('when rendering the name tabs component', () => { }) it('should add the active css class to the dcl tab', () => { - render() + render() expect(screen.getByText(ensTabText)).not.toHaveClass('active') + expect(screen.getByText(contributorTabText)).not.toHaveClass('active') expect(screen.getByText(dclTabText)).toHaveClass('active') }) }) + describe('when the tab param is contributor', () => { + beforeEach(() => { + useCurrentlySelectedTabResult.tab = TabType.CONTRIBUTOR + }) + + it('should add the active css class to the contributor tab', () => { + render() + + expect(screen.getByText(ensTabText)).not.toHaveClass('active') + expect(screen.getByText(contributorTabText)).toHaveClass('active') + expect(screen.getByText(dclTabText)).not.toHaveClass('active') + }) + }) + describe('when the dcl tab is clicked', () => { it('should call the on navigate prop with the current pathname + the tab query param with dcl as value', () => { - render() + render() screen.getByText(dclTabText).click() @@ -104,7 +120,7 @@ describe('when rendering the name tabs component', () => { describe('when the ens tab is clicked', () => { it('should call the on navigate prop with the current pathname + the tab query param with ens as value', () => { - render() + render() screen.getByText(ensTabText).click() @@ -115,5 +131,43 @@ describe('when rendering the name tabs component', () => { ) }) }) + + describe('when the contributor tab is clicked', () => { + it('should call the on navigate prop with the current pathname + the tab query param with contributor as value', () => { + render() + + screen.getByText(contributorTabText).click() + + expect(onNavigate).toHaveBeenCalledWith( + `${useCurrentlySelectedTabResult.pathname}?${useCurrentlySelectedTabResult.urlSearchParams.toString()}&${TAB_QUERY_PARAM_KEY}=${ + TabType.CONTRIBUTOR + }` + ) + }) + }) + }) +}) + +describe('when isWorldContributorEnabled is false', () => { + let useCurrentlySelectedTabResult: UseCurrentlySelectedTabResult + let onNavigate: jest.Mock + + beforeEach(() => { + useCurrentlySelectedTabResult = { + tab: TabType.DCL, + pathname: '/pathname', + urlSearchParams: new URLSearchParams('?foo=bar') + } as UseCurrentlySelectedTabResult + + mockUseCurrentlySelectedTab.mockReturnValueOnce(useCurrentlySelectedTabResult) + + onNavigate = jest.fn() + }) + + it('should not show the contributor tab', () => { + render() + expect(screen.getByText(dclTabText)).toBeInTheDocument() + expect(screen.getByText(ensTabText)).toBeInTheDocument() + expect(screen.queryByText(contributorTabText)).not.toBeInTheDocument() }) }) diff --git a/src/components/WorldListPage/NameTabs/NameTabs.tsx b/src/components/WorldListPage/NameTabs/NameTabs.tsx index f6a167f03..6a15a2c3d 100644 --- a/src/components/WorldListPage/NameTabs/NameTabs.tsx +++ b/src/components/WorldListPage/NameTabs/NameTabs.tsx @@ -4,7 +4,7 @@ import { t } from 'decentraland-dapps/dist/modules/translation/utils' import { Props } from './NameTabs.types' import { TAB_QUERY_PARAM_KEY, TabType, useCurrentlySelectedTab } from '../hooks' -const NameTabs = ({ onNavigate }: Props) => { +const NameTabs = ({ isWorldContributorEnabled, onNavigate }: Props) => { const { tab, pathname, urlSearchParams } = useCurrentlySelectedTab() const navigateToTab = (tab: TabType) => { @@ -13,7 +13,7 @@ const NameTabs = ({ onNavigate }: Props) => { onNavigate(`${pathname}?${urlSearchParamsCopy.toString()}`) } - if (!tab) { + if (!tab || (tab === TabType.CONTRIBUTOR && !isWorldContributorEnabled)) { navigateToTab(TabType.DCL) return null } @@ -26,6 +26,11 @@ const NameTabs = ({ onNavigate }: Props) => { navigateToTab(TabType.ENS)}> {t('worlds_list_page.name_tabs.ens_names')} + {isWorldContributorEnabled && ( + navigateToTab(TabType.CONTRIBUTOR)}> + {t('worlds_list_page.name_tabs.contributor_names')} + + )} ) } diff --git a/src/components/WorldListPage/NameTabs/NameTabs.types.ts b/src/components/WorldListPage/NameTabs/NameTabs.types.ts index da92b62ca..cf4e52089 100644 --- a/src/components/WorldListPage/NameTabs/NameTabs.types.ts +++ b/src/components/WorldListPage/NameTabs/NameTabs.types.ts @@ -2,8 +2,10 @@ import { CallHistoryMethodAction } from 'connected-react-router' import { Dispatch } from 'react' export type Props = { + isWorldContributorEnabled: boolean onNavigate: (to: string) => void } +export type MapStateProps = Pick export type MapDispatchProps = Pick export type MapDispatch = Dispatch diff --git a/src/components/WorldListPage/PublishSceneButton/PublishSceneButton.spec.tsx b/src/components/WorldListPage/PublishSceneButton/PublishSceneButton.spec.tsx new file mode 100644 index 000000000..99d15d054 --- /dev/null +++ b/src/components/WorldListPage/PublishSceneButton/PublishSceneButton.spec.tsx @@ -0,0 +1,109 @@ +import { render } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { t } from 'decentraland-dapps/dist/modules/translation' +import { ENS } from 'modules/ens/types' +import { Deployment } from 'modules/deployment/types' +import { Project } from 'modules/project/types' +import { Props } from './PublishSceneButton.types' +import PublishSceneButton from './PublishSceneButton' + +const ens = { + name: 'test', + subdomain: 'test', + content: '', + ensOwnerAddress: '0xtest1', + nftOwnerAddress: '0xtest1', + resolver: '0xtest3', + tokenId: '', + ensAddressRecord: '', + worldStatus: { + healthy: true + } +} as ENS + +let deploymentsByWorlds: Record +let projects: Project[] + +function renderPublishSceneButton(props: Partial) { + return render( + + ) +} + +describe('when the world has a scene deployed', () => { + beforeEach(() => { + deploymentsByWorlds = { + [ens.subdomain]: { + projectId: '1', + name: 'Deployment' + } as Deployment + } + }) + + describe('and the user has access to the deployed project', () => { + beforeEach(() => { + projects = [{ id: '1' } as Project] + }) + + it('should show the edit scene button', () => { + const screen = renderPublishSceneButton({ projects, deploymentsByWorlds }) + expect(screen.getByRole('button', { name: t('worlds_list_page.table.edit_scene') })).toBeInTheDocument() + }) + + describe('when the editScene button is clicked', () => { + it('should trigger onEditScene callback action', () => { + const onEditScene = jest.fn() + const screen = renderPublishSceneButton({ onEditScene, projects, deploymentsByWorlds }) + const editSceneButton = screen.getByRole('button', { name: t('worlds_list_page.table.edit_scene') }) + userEvent.click(editSceneButton) + expect(onEditScene).toHaveBeenCalled() + }) + }) + }) + + describe("and the user doesn't have access to the deployed project", () => { + beforeEach(() => { + projects = [] + }) + + it('should show the unpublish scene button', () => { + const screen = renderPublishSceneButton({ projects, deploymentsByWorlds }) + expect(screen.getByRole('button', { name: t('worlds_list_page.table.unpublish_scene') })).toBeInTheDocument() + }) + + describe('when the unpublish button is clicked', () => { + it('should trigger onUnpublish callback action', () => { + const onUnpublishScene = jest.fn() + const screen = renderPublishSceneButton({ onUnpublishScene, projects, deploymentsByWorlds }) + const unpublishSceneButton = screen.getByRole('button', { name: t('worlds_list_page.table.unpublish_scene') }) + userEvent.click(unpublishSceneButton) + expect(onUnpublishScene).toHaveBeenCalled() + }) + }) + }) +}) + +describe('when the world has no scene deployed', () => { + it('should show the publish scene button', () => { + const screen = renderPublishSceneButton({}) + expect(screen.getByRole('button', { name: t('worlds_list_page.table.publish_scene') })).toBeInTheDocument() + }) + + describe('when the publish button is clicked', () => { + it('should trigger onPublish callback action', () => { + const onPublishScene = jest.fn() + const screen = renderPublishSceneButton({ onPublishScene }) + const publishButton = screen.getByRole('button', { name: t('worlds_list_page.table.publish_scene') }) + userEvent.click(publishButton) + expect(onPublishScene).toHaveBeenCalled() + }) + }) +}) diff --git a/src/components/WorldListPage/PublishSceneButton/PublishSceneButton.tsx b/src/components/WorldListPage/PublishSceneButton/PublishSceneButton.tsx new file mode 100644 index 000000000..6db0e1e71 --- /dev/null +++ b/src/components/WorldListPage/PublishSceneButton/PublishSceneButton.tsx @@ -0,0 +1,47 @@ +import { Button, Popup } from 'decentraland-ui' +import { Props } from './PublishSceneButton.types' +import { isWorldDeployed } from '../utils' +import { t } from 'decentraland-dapps/dist/modules/translation' + +export default function PublishSceneButton({ + deploymentsByWorlds, + ens, + projects, + onEditScene, + onUnpublishScene, + onPublishScene +}: Props): JSX.Element { + const deployment = deploymentsByWorlds[ens.subdomain] + return isWorldDeployed(deploymentsByWorlds, ens) ? ( +
+ {deployment?.name}} /> + {projects.find(project => project.id === deployment?.projectId) + ? onEditScene && ( + + ) + : onUnpublishScene && ( + onUnpublishScene(ens)}> + {t('worlds_list_page.table.unpublish_scene')} + + } + /> + )} +
+ ) : ( +
+ - + {onPublishScene && ( + + )} +
+ ) +} diff --git a/src/components/WorldListPage/PublishSceneButton/PublishSceneButton.types.ts b/src/components/WorldListPage/PublishSceneButton/PublishSceneButton.types.ts new file mode 100644 index 000000000..a2f9b9482 --- /dev/null +++ b/src/components/WorldListPage/PublishSceneButton/PublishSceneButton.types.ts @@ -0,0 +1,12 @@ +import { Deployment } from 'modules/deployment/types' +import { ENS } from 'modules/ens/types' +import { Project } from 'modules/project/types' + +export type Props = { + deploymentsByWorlds: Record + ens: ENS + projects: Project[] + onEditScene?: (ens: ENS) => void + onUnpublishScene?: (ens: ENS) => void + onPublishScene?: () => void +} diff --git a/src/components/WorldListPage/PublishSceneButton/index.ts b/src/components/WorldListPage/PublishSceneButton/index.ts new file mode 100644 index 000000000..58efabd2b --- /dev/null +++ b/src/components/WorldListPage/PublishSceneButton/index.ts @@ -0,0 +1,3 @@ +import PublishSceneButton from './PublishSceneButton' + +export default PublishSceneButton diff --git a/src/components/WorldListPage/WorldContributorTab/WorldContributorTab.container.ts b/src/components/WorldListPage/WorldContributorTab/WorldContributorTab.container.ts new file mode 100644 index 000000000..fa033f218 --- /dev/null +++ b/src/components/WorldListPage/WorldContributorTab/WorldContributorTab.container.ts @@ -0,0 +1,28 @@ +import { connect } from 'react-redux' +import { push } from 'connected-react-router' +import { isLoadingType } from 'decentraland-dapps/dist/modules/loading' +import { getContributableNamesList, getLoading as getLoadingENS, getContributableNamesError } from 'modules/ens/selectors' +import { RootState } from 'modules/common/types' +import { getDeploymentsByWorlds, getLoading as getDeploymentLoading } from 'modules/deployment/selectors' +import { getProjects } from 'modules/ui/dashboard/selectors' +import { FETCH_WORLD_DEPLOYMENTS_REQUEST, clearDeploymentRequest } from 'modules/deployment/actions' +import { FETCH_CONTRIBUTABLE_NAMES_REQUEST } from 'modules/ens/actions' +import WorldContributorTab from './WorldContributorTab' +import { MapStateProp, MapDispatch, MapDispatchProps } from './WorldContributorTab.types' + +const mapState = (state: RootState): MapStateProp => ({ + items: getContributableNamesList(state), + deploymentsByWorlds: getDeploymentsByWorlds(state), + projects: getProjects(state), + error: getContributableNamesError(state), + loading: + isLoadingType(getLoadingENS(state), FETCH_CONTRIBUTABLE_NAMES_REQUEST) || + isLoadingType(getDeploymentLoading(state), FETCH_WORLD_DEPLOYMENTS_REQUEST) +}) + +const mapDispatch = (dispatch: MapDispatch): MapDispatchProps => ({ + onNavigate: path => dispatch(push(path)), + onUnpublishWorld: deploymentId => dispatch(clearDeploymentRequest(deploymentId)) +}) + +export default connect(mapState, mapDispatch)(WorldContributorTab) diff --git a/src/components/WorldListPage/WorldContributorTab/WorldContributorTab.spec.tsx b/src/components/WorldListPage/WorldContributorTab/WorldContributorTab.spec.tsx new file mode 100644 index 000000000..b64ee5d0f --- /dev/null +++ b/src/components/WorldListPage/WorldContributorTab/WorldContributorTab.spec.tsx @@ -0,0 +1,178 @@ +import { t } from 'decentraland-dapps/dist/modules/translation' +import { renderWithProviders } from 'specs/utils' +import { Deployment } from 'modules/deployment/types' +import { ENS } from 'modules/ens/types' +import { fromBytesToMegabytes, getExplorerUrl } from '../utils' +import WorldContributorTab from './WorldContributorTab' +import { Props } from './WorldContributorTab.types' +import userEvent from '@testing-library/user-event' + +export function renderWorldContributorTab(props: Partial) { + return renderWithProviders( + + ) +} + +describe('when loading the items', () => { + it('should show spinner', () => { + const screen = renderWorldContributorTab({ loading: true }) + expect(screen.container.getElementsByClassName('loader').length).toBe(1) + }) +}) + +describe('when rendering contributable names table', () => { + let deploymentsByWorlds: Record + let items: ENS[] + + describe('when the user has no contributable names', () => { + beforeEach(() => { + items = [] + }) + + it('should show empty placeholder', () => { + const screen = renderWorldContributorTab({ items }) + expect(screen.getByText(t('worlds_list_page.empty_contributor_list.title'))) + }) + }) + + describe('when the user has contributable names', () => { + beforeEach(() => { + items = [ + { + name: 'test.dcl.eth', + subdomain: 'test.dcl.eth', + content: '', + ensOwnerAddress: '0xtest1', + nftOwnerAddress: '0xtest1', + resolver: '0xtest3', + tokenId: '', + ensAddressRecord: '', + size: '1048576', + userPermissions: ['deployment'], + worldStatus: { + healthy: true, + scene: { urn: 'urn', entityId: 'entityId' } + } + } + ] + }) + + it("should show item's name", () => { + const screen = renderWorldContributorTab({ items }) + expect(screen.getByText(items[0].name)).toBeInTheDocument() + }) + + it("should show item's size", () => { + const screen = renderWorldContributorTab({ items }) + expect(screen.getByText(fromBytesToMegabytes(Number(items[0].size!)))).toBeInTheDocument() + }) + + it("should show item's permissions", () => { + const screen = renderWorldContributorTab({ items }) + expect(screen.getByText(t(`worlds_list_page.table.user_permissions.${items[0].userPermissions![0]}`))).toBeInTheDocument() + }) + + describe('when the world has a scene deployed', () => { + beforeEach(() => { + deploymentsByWorlds = { + ['test.dcl.eth']: { + projectId: '1', + id: '1' + } as Deployment + } + }) + + it("should show world's url", () => { + const screen = renderWorldContributorTab({ items, deploymentsByWorlds }) + expect(screen.getByText(getExplorerUrl(items[0].subdomain))) + }) + + describe('and the user has deployment permissions', () => { + beforeEach(() => { + items = [ + { + ...items[0], + userPermissions: ['deployment'] + } + ] + }) + + it('should show unpublish scene button', () => { + const screen = renderWorldContributorTab({ items, deploymentsByWorlds }) + expect(screen.getByRole('button', { name: t('worlds_list_page.table.unpublish_scene') })).toBeInTheDocument() + }) + + it('should trigger onUnpublishWorld action when unpublish button is clicked', () => { + const onUnpublishWorld = jest.fn() + const screen = renderWorldContributorTab({ items, deploymentsByWorlds, onUnpublishWorld }) + const unpublishBtn = screen.getByRole('button', { name: t('worlds_list_page.table.unpublish_scene') }) + userEvent.click(unpublishBtn) + expect(onUnpublishWorld).toHaveBeenCalled() + }) + }) + + describe("and the user doesn't have deployment permissions", () => { + beforeEach(() => { + items = [ + { + ...items[0], + userPermissions: ['streaming'] + } + ] + }) + + it('should not show unpublish scene button', () => { + const screen = renderWorldContributorTab({ items, deploymentsByWorlds }) + expect(screen.queryByRole('button', { name: t('worlds_list_page.table.unpublish_scene') })).not.toBeInTheDocument() + }) + }) + }) + + describe('when the world has no scene deployed', () => { + beforeEach(() => { + deploymentsByWorlds = {} + }) + + describe('and the user has deployment permissions', () => { + beforeEach(() => { + items = [ + { + ...items[0], + userPermissions: ['deployment'] + } + ] + }) + + it('should show publish scene button', () => { + const screen = renderWorldContributorTab({ items, deploymentsByWorlds }) + expect(screen.getByRole('button', { name: t('worlds_list_page.table.publish_scene') })).toBeInTheDocument() + }) + }) + + describe("and the user doesn't have deployment permissions", () => { + beforeEach(() => { + items = [ + { + ...items[0], + userPermissions: ['streaming'] + } + ] + }) + + it('should not show publish scene button', () => { + const screen = renderWorldContributorTab({ items, deploymentsByWorlds }) + expect(screen.queryByRole('button', { name: t('worlds_list_page.table.publish_scene') })).not.toBeInTheDocument() + }) + }) + }) + }) +}) diff --git a/src/components/WorldListPage/WorldContributorTab/WorldContributorTab.tsx b/src/components/WorldListPage/WorldContributorTab/WorldContributorTab.tsx new file mode 100644 index 000000000..b449b441c --- /dev/null +++ b/src/components/WorldListPage/WorldContributorTab/WorldContributorTab.tsx @@ -0,0 +1,101 @@ +import { t } from 'decentraland-dapps/dist/modules/translation' +import { Loader, Message, Table, Empty, Button } from 'decentraland-ui' +import { formatNumber } from 'decentraland-dapps/dist/lib' +import { useCallback } from 'react' +import { config } from 'config' +import { locations } from 'routing/locations' +import Profile from 'components/Profile' +import { ENS } from 'modules/ens/types' +import { fromBytesToMegabytes } from '../utils' +import { Props } from './WorldContributorTab.types' +import WorldUrl from '../WorldUrl' +import PublishSceneButton from '../PublishSceneButton' + +export default function WorldContributorTab({ items, deploymentsByWorlds, projects, loading, error, onNavigate, onUnpublishWorld }: Props) { + const handlePublishScene = useCallback(() => { + onNavigate(locations.scenes()) + }, [onNavigate]) + + const handleEditScene = useCallback( + (ens: ENS) => { + const { projectId } = deploymentsByWorlds[ens.subdomain] + onNavigate(locations.sceneDetail(projectId as string)) + }, + [deploymentsByWorlds, locations, onNavigate] + ) + + const handleUnpublishScene = useCallback( + (ens: ENS) => { + const deploymentId = deploymentsByWorlds[ens.subdomain]?.id + if (deploymentId) { + onUnpublishWorld(deploymentId) + } + }, + [deploymentsByWorlds, onUnpublishWorld] + ) + + if (error) { + return + } + + if (loading) { + return + } + + if (!items.length) { + return ( + +
+
{t('worlds_list_page.empty_contributor_list.title')}
+
+ {t('worlds_list_page.empty_contributor_list.description', { b: (text: string) => {text} })} +
+ + + ) + } + + return ( + + + + {t('worlds_list_page.table.name')} + {t('worlds_list_page.table.owner')} + {t('worlds_list_page.table.url')} + {t('worlds_list_page.table.published_scene')} + {t('worlds_list_page.table.size')} + {t('worlds_list_page.table.permissions')} + + + + {items.map((ens: ENS, index) => { + const canUserDeploy = ens.userPermissions?.includes('deployment') + const userPermissions = ens.userPermissions + ?.map(permission => t(`worlds_list_page.table.user_permissions.${permission}`)) + .join('/') + return ( + + {ens.name} + {} + {} + + + + {formatNumber(fromBytesToMegabytes(Number(ens.size) || 0))} + {userPermissions} + + ) + })} + +
+ ) +} diff --git a/src/components/WorldListPage/WorldContributorTab/WorldContributorTab.types.ts b/src/components/WorldListPage/WorldContributorTab/WorldContributorTab.types.ts new file mode 100644 index 000000000..3b10e6dca --- /dev/null +++ b/src/components/WorldListPage/WorldContributorTab/WorldContributorTab.types.ts @@ -0,0 +1,20 @@ +import { Dispatch } from 'redux' +import { Deployment } from 'modules/deployment/types' +import { ENS, ENSError } from 'modules/ens/types' +import { Project } from 'modules/project/types' +import { clearDeploymentRequest } from 'modules/deployment/actions' + +export type Props = { + items: ENS[] + projects: Project[] + deploymentsByWorlds: Record + loading: boolean + error: ENSError | null + onNavigate: (path: string) => void + onUnpublishWorld: typeof clearDeploymentRequest +} + +export type MapStateProp = Pick + +export type MapDispatchProps = Pick +export type MapDispatch = Dispatch diff --git a/src/components/WorldListPage/WorldContributorTab/index.ts b/src/components/WorldListPage/WorldContributorTab/index.ts new file mode 100644 index 000000000..c09d84939 --- /dev/null +++ b/src/components/WorldListPage/WorldContributorTab/index.ts @@ -0,0 +1,3 @@ +import WorldContributorTab from './WorldContributorTab.container' + +export default WorldContributorTab diff --git a/src/components/WorldListPage/WorldListPage.container.ts b/src/components/WorldListPage/WorldListPage.container.ts index db1e1b539..c6a67e8a3 100644 --- a/src/components/WorldListPage/WorldListPage.container.ts +++ b/src/components/WorldListPage/WorldListPage.container.ts @@ -1,8 +1,11 @@ import { connect } from 'react-redux' import { push } from 'connected-react-router' import { isLoadingType } from 'decentraland-dapps/dist/modules/loading/selectors' +import { openModal } from 'decentraland-dapps/dist/modules/modal/actions' +import { isConnected } from 'decentraland-dapps/dist/modules/wallet' import { RootState } from 'modules/common/types' -import { FETCH_ENS_LIST_REQUEST, FETCH_EXTERNAL_NAMES_REQUEST } from 'modules/ens/actions' +import { getIsWorldContributorEnabled } from 'modules/features/selectors' +import { FETCH_ENS_LIST_REQUEST, FETCH_EXTERNAL_NAMES_REQUEST, fetchContributableNamesRequest } from 'modules/ens/actions' import { getENSByWallet, getError as getENSError, @@ -18,7 +21,6 @@ import { getProjects } from 'modules/ui/dashboard/selectors' import { getConnectedWalletStats, getLoading as getLoadingWorlds, getWorldsPermissions } from 'modules/worlds/selectors' import { MapStateProps, MapDispatchProps, MapDispatch } from './WorldListPage.types' import WorldListPage from './WorldListPage' -import { openModal } from 'decentraland-dapps/dist/modules/modal/actions' const mapState = (state: RootState): MapStateProps => ({ ensList: getENSByWallet(state), @@ -35,7 +37,9 @@ const mapState = (state: RootState): MapStateProps => ({ isLoadingType(getLoadingENS(state), FETCH_EXTERNAL_NAMES_REQUEST) || isLoggingIn(state), isLoggedIn: isLoggedIn(state), - worldsWalletStats: getConnectedWalletStats(state) + worldsWalletStats: getConnectedWalletStats(state), + isConnected: isConnected(state), + isWorldContributorEnabled: getIsWorldContributorEnabled(state) }) const mapDispatch = (dispatch: MapDispatch): MapDispatchProps => ({ @@ -44,7 +48,8 @@ const mapDispatch = (dispatch: MapDispatch): MapDispatchProps => ({ onOpenPermissionsModal: (name, isCollaboratorsTabShown) => dispatch(openModal('WorldPermissionsModal', { worldName: name, isCollaboratorsTabShown })), onOpenWorldsForENSOwnersAnnouncementModal: () => dispatch(openModal('WorldsForENSOwnersAnnouncementModal')), - onUnpublishWorld: deploymentId => dispatch(clearDeploymentRequest(deploymentId)) + onUnpublishWorld: deploymentId => dispatch(clearDeploymentRequest(deploymentId)), + onFetchContributableNames: () => dispatch(fetchContributableNamesRequest()) }) export default connect(mapState, mapDispatch)(WorldListPage) diff --git a/src/components/WorldListPage/WorldListPage.css b/src/components/WorldListPage/WorldListPage.css index 3b3f31bf0..0c8ac6971 100644 --- a/src/components/WorldListPage/WorldListPage.css +++ b/src/components/WorldListPage/WorldListPage.css @@ -61,22 +61,6 @@ flex-direction: row; } -.WorldListPage .TableRow .world-url { - display: flex; - width: 100%; - justify-content: space-between; -} - -.WorldListPage .TableRow .world-url .right { - display: flex; - align-items: center; - margin-right: 20px; -} - -.WorldListPage .TableRow .world-url .right .link { - color: var(--text); -} - .WorldListPage .TableRow .empty-url { color: #ffffffcc; font-style: italic; diff --git a/src/components/WorldListPage/WorldListPage.tsx b/src/components/WorldListPage/WorldListPage.tsx index 6ee38ca4d..339806b8c 100644 --- a/src/components/WorldListPage/WorldListPage.tsx +++ b/src/components/WorldListPage/WorldListPage.tsx @@ -2,28 +2,13 @@ import React, { ReactNode, useCallback, useEffect, useState } from 'react' import classNames from 'classnames' import { t } from 'decentraland-dapps/dist/modules/translation/utils' import { formatNumber } from 'decentraland-dapps/dist/lib/utils' -import { - Button, - Table, - Row, - Column, - Header, - Section, - Container, - Pagination, - Dropdown, - Empty, - Icon as DCLIcon, - Popup -} from 'decentraland-ui' +import { Button, Table, Row, Column, Header, Section, Container, Pagination, Dropdown, Empty } from 'decentraland-ui' import { config } from 'config' -import { isDevelopment } from 'lib/environment' import { WorldsWalletStats } from 'lib/api/worlds' import { ENS } from 'modules/ens/types' import { isExternalName } from 'modules/ens/utils' import { track } from 'modules/analytics/sagas' import { locations } from 'routing/locations' -import CopyToClipboard from 'components/CopyToClipboard/CopyToClipboard' import Icon from 'components/Icon' import LoggedInDetailPage from 'components/LoggedInDetailPage' import { NavigationTab } from 'components/Navigation/Navigation.types' @@ -31,13 +16,14 @@ import { canOpenWorldsForENSOwnersAnnouncementModal } from 'components/Modals/Wo import { Props, SortBy } from './WorldListPage.types' import NameTabs from './NameTabs' import WorldsStorage from './WorldsStorage' +import WorldContributorTab from './WorldContributorTab' +import WorldUrl from './WorldUrl' +import PublishSceneButton from './PublishSceneButton' import { TabType, useCurrentlySelectedTab } from './hooks' -import { DCLWorldsStatus, fromBytesToMegabytes, getDCLWorldsStatus } from './utils' +import { DCLWorldsStatus, fromBytesToMegabytes, getDCLWorldsStatus, isWorldDeployed } from './utils' import './WorldListPage.css' const PAGE_ACTION_EVENT = 'Worlds List Page Action' -const EXPLORER_URL = config.get('EXPLORER_URL', '') -const WORLDS_CONTENT_SERVER_URL = config.get('WORLDS_CONTENT_SERVER', '') const ENS_DOMAINS_URL = config.get('ENS_DOMAINS_URL', '') const MARKETPLACE_WEB_URL = config.get('MARKETPLACE_WEB_URL') const PAGE_SIZE = 12 @@ -51,36 +37,24 @@ const WorldListPage: React.FC = props => { isLoading, projects, worldsWalletStats, + isConnected, + isWorldContributorEnabled, onNavigate, onOpenYourStorageModal, onOpenWorldsForENSOwnersAnnouncementModal, onUnpublishWorld, - onOpenPermissionsModal + onOpenPermissionsModal, + onFetchContributableNames } = props const [sortBy, setSortBy] = useState(SortBy.ASC) const [page, setPage] = useState(1) const { tab } = useCurrentlySelectedTab() - const isWorldDeployed = useCallback( - (ens: ENS) => { - if (ens.worldStatus?.healthy) { - return !!deploymentsByWorlds[ens.subdomain] - } - - return false - }, - [deploymentsByWorlds] - ) - - const getExplorerUrl = useCallback( - (world: string) => { - if (isDevelopment) { - return `${EXPLORER_URL}/?realm=${WORLDS_CONTENT_SERVER_URL}/world/${world}&NETWORK=sepolia` - } - return `${EXPLORER_URL}/world/${world}` - }, - [isDevelopment] - ) + useEffect(() => { + if (isConnected && isWorldContributorEnabled) { + onFetchContributableNames() + } + }, [isConnected, isWorldContributorEnabled]) const handleClaimENS = useCallback(() => { if (tab === TabType.DCL) { @@ -148,31 +122,9 @@ const WorldListPage: React.FC = props => { .slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE) }, [ensList, externalNames, page, sortBy, tab]) - const renderWorldUrl = useCallback( - (ens: ENS) => { - const url = getExplorerUrl(ens.subdomain) - return isWorldDeployed(ens) ? ( -
- {url} -
- - - - - - -
-
- ) : ( - {t('worlds_list_page.table.empty_url')} - ) - }, - [getExplorerUrl, isWorldDeployed] - ) - const renderWorldStatus = useCallback( (ens: ENS) => { - let status = isWorldDeployed(ens) ? 'active' : 'inactive' + let status = isWorldDeployed(deploymentsByWorlds, ens) ? 'active' : 'inactive' if (status === 'active' && worldsWalletStats && !isExternalName(ens.subdomain)) { const worldsStatus = getDCLWorldsStatus(worldsWalletStats) @@ -193,46 +145,11 @@ const WorldListPage: React.FC = props => { [getDCLWorldsStatus, isExternalName, isWorldDeployed] ) - const renderPublishSceneButton = useCallback( - (ens: ENS) => { - const deployment = deploymentsByWorlds[ens.subdomain] - return isWorldDeployed(ens) ? ( -
- {deployment?.name}} /> - {projects.find(project => project.id === deployment?.projectId) ? ( - - ) : ( - handleUnpublishScene(ens)}> - {t('worlds_list_page.table.unpublish_scene')} - - } - /> - )} -
- ) : ( -
- - - -
- ) - }, - [deploymentsByWorlds, projects, isWorldDeployed, handleEditScene, handlePublishScene, handleUnpublishScene] - ) - const renderWorldSize = useCallback( (ens: ENS, stats?: WorldsWalletStats) => { const names = tab === TabType.DCL ? stats?.dclNames : stats?.ensNames - if (!isWorldDeployed(ens) || !names) { + if (!isWorldDeployed(deploymentsByWorlds, ens) || !names) { return '-' } @@ -241,7 +158,7 @@ const WorldListPage: React.FC = props => { return formatNumber(fromBytesToMegabytes(Number(bytes))) + suffix }, - [tab, isWorldDeployed] + [tab] ) const handleOpenPermissionsModal = useCallback((_event: React.MouseEvent, worldName: string) => { @@ -302,8 +219,17 @@ const WorldListPage: React.FC = props => { return ( {ens.name} - {renderWorldUrl(ens)} - {renderPublishSceneButton(ens)} + {} + + + {renderWorldSize(ens, worldsWalletStats)} @@ -334,19 +260,7 @@ const WorldListPage: React.FC = props => { ) - }, [ - tab, - ensList, - externalNames, - handleClaimENS, - paginate, - renderPublishSceneButton, - renderSortDropdown, - renderWorldUrl, - renderWorldSize, - renderWorldStatus, - setPage - ]) + }, [tab, ensList, externalNames, handleClaimENS, paginate, renderSortDropdown, renderWorldSize, renderWorldStatus, setPage]) const renderEmptyPage = useCallback(() => { return ( @@ -452,7 +366,9 @@ const WorldListPage: React.FC = props => {

{t('worlds_list_page.title')}

- {tab === TabType.DCL ? renderDCLNamesView() : renderENSNamesView()} + {tab === TabType.DCL && renderDCLNamesView()} + {tab === TabType.ENS && renderENSNamesView()} + {tab === TabType.CONTRIBUTOR && isWorldContributorEnabled && }
) diff --git a/src/components/WorldListPage/WorldListPage.types.ts b/src/components/WorldListPage/WorldListPage.types.ts index 640c3c11c..3ced1e640 100644 --- a/src/components/WorldListPage/WorldListPage.types.ts +++ b/src/components/WorldListPage/WorldListPage.types.ts @@ -21,12 +21,15 @@ export type Props = { worldsPermissions: Record isLoading: boolean worldsWalletStats?: WorldsWalletStats + isConnected: boolean + isWorldContributorEnabled: boolean onNavigate: (path: string) => void onOpenYourStorageModal: (metadata: WorldsYourStorageModalMetadata) => void onOpenPermissionsModal: (worldName: string, isCollaboratorsTabShown?: boolean) => void onOpenWorldsForENSOwnersAnnouncementModal: () => void getProfiles: (worldName: string) => void onUnpublishWorld: typeof clearDeploymentRequest + onFetchContributableNames: () => void } export type State = { @@ -45,9 +48,16 @@ export type MapStateProps = Pick< | 'isLoggedIn' | 'worldsWalletStats' | 'worldsPermissions' + | 'isConnected' + | 'isWorldContributorEnabled' > export type MapDispatchProps = Pick< Props, - 'onNavigate' | 'onOpenYourStorageModal' | 'onOpenPermissionsModal' | 'onOpenWorldsForENSOwnersAnnouncementModal' | 'onUnpublishWorld' + | 'onNavigate' + | 'onOpenYourStorageModal' + | 'onOpenPermissionsModal' + | 'onOpenWorldsForENSOwnersAnnouncementModal' + | 'onUnpublishWorld' + | 'onFetchContributableNames' > export type MapDispatch = Dispatch diff --git a/src/components/WorldListPage/WorldUrl/WorldUrl.module.css b/src/components/WorldListPage/WorldUrl/WorldUrl.module.css new file mode 100644 index 000000000..8d1157393 --- /dev/null +++ b/src/components/WorldListPage/WorldUrl/WorldUrl.module.css @@ -0,0 +1,15 @@ +.worldUrl { + display: flex; + width: 100%; + justify-content: space-between; +} + +.rightContainer { + display: flex; + align-items: center; + margin-right: 20px; +} + +.rightContainer .copyUrn { + color: var(--text); +} diff --git a/src/components/WorldListPage/WorldUrl/WorldUrl.spec.tsx b/src/components/WorldListPage/WorldUrl/WorldUrl.spec.tsx new file mode 100644 index 000000000..13b486863 --- /dev/null +++ b/src/components/WorldListPage/WorldUrl/WorldUrl.spec.tsx @@ -0,0 +1,39 @@ +import { render } from '@testing-library/react' +import { ENS } from 'modules/ens/types' +import WorldUrl from './WorldUrl' +import { Props } from './WorldUrl.types' +import { Deployment } from 'modules/deployment/types' +import { getExplorerUrl } from '../utils' +import { t } from 'decentraland-dapps/dist/modules/translation' + +const ens = { + name: 'test.dcl.eth', + subdomain: 'test.dcl.eth', + worldStatus: { healthy: true } +} as ENS + +let deploymentsByWorlds: Record + +function renderWorldUrl(props: Partial) { + return render() +} + +describe('when the world has a scene deployed', () => { + it('should show the world url', () => { + deploymentsByWorlds = { + [ens.subdomain]: { + projectId: 'projectId' + } as Deployment + } + const screen = renderWorldUrl({ deploymentsByWorlds }) + expect(screen.getByText(getExplorerUrl(ens.subdomain))).toBeInTheDocument() + }) +}) + +describe('when the world has no scene deployed', () => { + it('should show the world url', () => { + deploymentsByWorlds = {} + const screen = renderWorldUrl({ deploymentsByWorlds }) + expect(screen.getByText(t('worlds_list_page.table.empty_url'))).toBeInTheDocument() + }) +}) diff --git a/src/components/WorldListPage/WorldUrl/WorldUrl.tsx b/src/components/WorldListPage/WorldUrl/WorldUrl.tsx new file mode 100644 index 000000000..c20b48fa8 --- /dev/null +++ b/src/components/WorldListPage/WorldUrl/WorldUrl.tsx @@ -0,0 +1,26 @@ +import { Icon } from 'decentraland-ui' +import { t } from 'decentraland-dapps/dist/modules/translation' +import CopyToClipboard from 'components/CopyToClipboard/CopyToClipboard' +import { getExplorerUrl, isWorldDeployed } from '../utils' +import { Props } from './WorldUrl.types' +import styles from './WorldUrl.module.css' +import classNames from 'classnames' + +export default function WorldUrl({ ens, deploymentsByWorlds }: Props) { + const url = getExplorerUrl(ens.subdomain) + return isWorldDeployed(deploymentsByWorlds, ens) ? ( +
+ {url} +
+ + + + + + +
+
+ ) : ( + {t('worlds_list_page.table.empty_url')} + ) +} diff --git a/src/components/WorldListPage/WorldUrl/WorldUrl.types.ts b/src/components/WorldListPage/WorldUrl/WorldUrl.types.ts new file mode 100644 index 000000000..e6281235f --- /dev/null +++ b/src/components/WorldListPage/WorldUrl/WorldUrl.types.ts @@ -0,0 +1,7 @@ +import { Deployment } from 'modules/deployment/types' +import { ENS } from 'modules/ens/types' + +export type Props = { + deploymentsByWorlds: Record + ens: ENS +} diff --git a/src/components/WorldListPage/WorldUrl/index.ts b/src/components/WorldListPage/WorldUrl/index.ts new file mode 100644 index 000000000..266aaf42f --- /dev/null +++ b/src/components/WorldListPage/WorldUrl/index.ts @@ -0,0 +1,3 @@ +import WorldUrl from './WorldUrl' + +export default WorldUrl diff --git a/src/components/WorldListPage/hooks.ts b/src/components/WorldListPage/hooks.ts index b6b383755..1a1e9df55 100644 --- a/src/components/WorldListPage/hooks.ts +++ b/src/components/WorldListPage/hooks.ts @@ -4,7 +4,8 @@ export const TAB_QUERY_PARAM_KEY = 'tab' export enum TabType { DCL = 'dcl', - ENS = 'ens' + ENS = 'ens', + CONTRIBUTOR = 'contributor' } export type UseCurrentlySelectedTabResult = { @@ -30,6 +31,9 @@ export const useCurrentlySelectedTab = (): UseCurrentlySelectedTabResult => { case 'ens': result.tab = TabType.ENS break + case 'contributor': + result.tab = TabType.CONTRIBUTOR + break } return result diff --git a/src/components/WorldListPage/utils.ts b/src/components/WorldListPage/utils.tsx similarity index 63% rename from src/components/WorldListPage/utils.ts rename to src/components/WorldListPage/utils.tsx index ef9560954..1b652810a 100644 --- a/src/components/WorldListPage/utils.ts +++ b/src/components/WorldListPage/utils.tsx @@ -1,9 +1,16 @@ import { WorldsWalletStats } from 'lib/api/worlds' +import { ENS } from 'modules/ens/types' +import { Deployment } from 'modules/deployment/types' +import { isDevelopment } from 'lib/environment' +import { config } from 'config' export const fromBytesToMegabytes = (bytes: number) => { return bytes / 1024 / 1024 } +const EXPLORER_URL = config.get('EXPLORER_URL', '') +const WORLDS_CONTENT_SERVER_URL = config.get('WORLDS_CONTENT_SERVER', '') + export const BLOCK_DELAY_IN_MILLISECONDS = 48 * 60 * 60 * 1000 // 48 hours export enum DCLWorldsStatus { @@ -49,3 +56,17 @@ export const getDCLWorldsStatus = (stats: WorldsWalletStats): GetDCLWorldsStatus toBeBlockedAt: blockedAt } } + +export const isWorldDeployed = (deploymentsByWorlds: Record, ens: ENS) => { + if (ens.worldStatus?.healthy) { + return !!deploymentsByWorlds[ens.subdomain] + } + return false +} + +export const getExplorerUrl = (world: string) => { + if (isDevelopment) { + return `${EXPLORER_URL}/?realm=${WORLDS_CONTENT_SERVER_URL}/world/${world}&NETWORK=sepolia` + } + return `${EXPLORER_URL}/world/${world}` +} diff --git a/src/lib/api/ens.ts b/src/lib/api/ens.ts index 5cf46afed..89e99e3b3 100644 --- a/src/lib/api/ens.ts +++ b/src/lib/api/ens.ts @@ -1,9 +1,37 @@ -type Domain = { name: string } +import { config } from 'config' +import { gql } from 'apollo-boost' +import { createClient } from './graph' + +export const ENS_SUBGRAPH_URL = config.get('ENS_SUBGRAPH_URL', '') +const ensGraphClient = createClient(ENS_SUBGRAPH_URL) +type Domain = { name: string } type DomainsQueryResult = { data: { domains: Domain[] } } | { errors: any } +type OwnerByENSTuple = { + name: string + wrappedOwner: { + id: string + } +} + +type OwnerByENSQueryResult = { + domains: OwnerByENSTuple[] +} + const FAIL_TO_FETCH_ENS_LIST_MESSAGE = 'Failed to fetch ENS list' +const getOwnerByENSQuery = () => gql` + query getOwners($domains: [String]) { + domains(where: { name_in: $domains }) { + name + wrappedOwner { + id + } + } + } +` + export class ENSApi { constructor(private subgraph: string) {} @@ -49,4 +77,22 @@ export class ENSApi { return queryResult.data.domains.map(domain => domain.name) } + + fetchExternalENSOwners = async (domains: string[]): Promise> => { + if (!domains) { + return {} + } + + const { data } = await ensGraphClient.query({ + query: getOwnerByENSQuery(), + variables: { domains } + }) + + const results: Record = {} + data.domains.forEach(({ wrappedOwner, name }) => { + results[name] = wrappedOwner.id + }) + + return results + } } diff --git a/src/lib/api/marketplace.ts b/src/lib/api/marketplace.ts index 0842dca94..4e271df3d 100644 --- a/src/lib/api/marketplace.ts +++ b/src/lib/api/marketplace.ts @@ -17,6 +17,19 @@ const getSubdomainQuery = () => gql` } ` +const getOwnerByNameQuery = () => gql` + query getOwners($domains: [String], $offset: Int) { + nfts(first: ${BATCH_SIZE}, skip: $offset, where: { name_in: $domains, category: ens }) { + owner { + address + } + ens { + subdomain + } + } + } +` + type SubdomainTuple = { ens: { subdomain: string[] @@ -27,7 +40,44 @@ type SubdomainQueryResult = { nfts: SubdomainTuple[] } +type OwnerByNameTuple = { + owner: { + address: string + } + ens: { + subdomain: string + } +} +type OwnerByNameQueryResult = { + nfts: OwnerByNameTuple[] +} + export class MarketplaceAPI { + public async fetchENSOwnerByDomain(domains: string[]): Promise> { + if (!domains) { + return {} + } + + const results: Record = {} + let offset = 0 + let nextPage = true + while (nextPage) { + const { data } = await marketplaceGraphClient.query({ + query: getOwnerByNameQuery(), + variables: { domains, offset } + }) + data.nfts.forEach(({ ens, owner }) => { + results[ens.subdomain] = owner.address + }) + if (data.nfts.length === BATCH_SIZE) { + offset += BATCH_SIZE + } else { + nextPage = false + } + } + return results + } + public async fetchENSList(address: string | undefined): Promise { if (!address) { return [] diff --git a/src/lib/api/worlds.spec.ts b/src/lib/api/worlds.spec.ts index 818b35424..93d443b86 100644 --- a/src/lib/api/worlds.spec.ts +++ b/src/lib/api/worlds.spec.ts @@ -128,3 +128,29 @@ describe('when fetching the world permissions for a wallet', () => { }) }) }) + +describe('when fetching contributable names for a wallet', () => { + beforeEach(() => { + worldsApi = new MockWorldsAPI(new AuthMock()) + }) + + describe('when there is an error fetching the names', () => { + beforeEach(() => { + global.fetch = () => Promise.resolve({ ok: false } as Response) + }) + + it('should throw an error', async () => { + await expect(worldsApi.fetchContributableDomains()).rejects.toThrow('Error while fetching contributable domains') + }) + }) + + describe('when the names could be fetched successfully', () => { + beforeEach(() => { + global.fetch = () => Promise.resolve({ ok: true, json: () => Promise.resolve({ domains: [] }) } as Response) + }) + + it('should return domains value', async () => { + await expect(worldsApi.fetchContributableDomains()).resolves.toEqual([]) + }) + }) +}) diff --git a/src/lib/api/worlds.ts b/src/lib/api/worlds.ts index d8a41ace7..3ac5e63c1 100644 --- a/src/lib/api/worlds.ts +++ b/src/lib/api/worlds.ts @@ -1,5 +1,6 @@ import { BaseAPI } from 'decentraland-dapps/dist/lib/api' import { config } from 'config' +import { ContributableDomain } from 'modules/ens/types' import { Authorization } from './auth' export const WORLDS_CONTENT_SERVER = config.get('WORLDS_CONTENT_SERVER', '') @@ -157,4 +158,24 @@ export class WorldsAPI extends BaseAPI { }) return result.status === 204 } + + public fetchContributableDomains = async () => { + if (!this.authorization) { + throw new Error('Unauthorized') + } + + const path = '/wallet/contribute' + const headers = this.authorization.createAuthHeaders('get', path, {}) + const result = await fetch(this.url + path, { + method: 'GET', + headers + }) + + if (result.ok) { + const json: { domains: ContributableDomain[] } = await result.json() + return json.domains + } else { + throw new Error('Error while fetching contributable domains') + } + } } diff --git a/src/modules/analytics/sagas.ts b/src/modules/analytics/sagas.ts index cbdc970a6..55614c2b3 100644 --- a/src/modules/analytics/sagas.ts +++ b/src/modules/analytics/sagas.ts @@ -76,7 +76,7 @@ export function* analyticsSaga() { yield all([baseAnalyticsSaga(), builderAnalyticsSaga()]) } -export const track = (event: string, params: any) => getAnalytics().track(event, params) as void +export const track = (event: string, params: any) => getAnalytics().track(event, params) function handlePublishTPItemSuccess(action: PublishThirdPartyItemsSuccessAction) { const { items } = action.payload diff --git a/src/modules/ens/actions.ts b/src/modules/ens/actions.ts index e37cdfa51..1bbee0953 100644 --- a/src/modules/ens/actions.ts +++ b/src/modules/ens/actions.ts @@ -139,3 +139,15 @@ export type ClearENSErrorsAction = ReturnType // Legacy claim name and name allowance actions left here to avoid breaking the activity feed export const ALLOW_CLAIM_MANA_SUCCESS = '[Success] Allow Claim MANA' export const CLAIM_NAME_TRANSACTION_SUBMITTED = '[Submitted] Claim Name' + +export const FETCH_CONTRIBUTABLE_NAMES_REQUEST = '[Request] Fetch Contributable Names' +export const FETCH_CONTRIBUTABLE_NAMES_SUCCESS = '[Success] Fetch Contributable Names' +export const FETCH_CONTRIBUTABLE_NAMES_FAILURE = '[Failure] Fetch Contributable Names' + +export const fetchContributableNamesRequest = () => action(FETCH_CONTRIBUTABLE_NAMES_REQUEST) +export const fetchContributableNamesSuccess = (names: ENS[]) => action(FETCH_CONTRIBUTABLE_NAMES_SUCCESS, { names }) +export const fetchContributableNamesFailure = (error: ENSError) => action(FETCH_CONTRIBUTABLE_NAMES_FAILURE, { error }) + +export type FetchContributableNamesRequestAction = ReturnType +export type FetchContributableNamesSuccessAction = ReturnType +export type FetchContributableNamesFailureAction = ReturnType diff --git a/src/modules/ens/reducer.spec.ts b/src/modules/ens/reducer.spec.ts index 1532a0ac6..48ffb382e 100644 --- a/src/modules/ens/reducer.spec.ts +++ b/src/modules/ens/reducer.spec.ts @@ -3,6 +3,9 @@ import { FETCH_EXTERNAL_NAMES_REQUEST, SET_ENS_ADDRESS_SUCCESS, clearENSErrors, + fetchContributableNamesFailure, + fetchContributableNamesRequest, + fetchContributableNamesSuccess, fetchExternalNamesFailure, fetchExternalNamesRequest, fetchExternalNamesSuccess, @@ -200,3 +203,87 @@ describe('when handling the CLEAR_ENS_ERRORS action', () => { expect(newState.error).toEqual(null) }) }) + +describe('when handling fetch contributable names actions', () => { + describe('when handling fetch contributable names request action', () => { + it('should reset the contributable names error', () => { + const stateWithError = { + ...state, + contributableNamesError: { message: 'an error' } + } + expect(ensReducer(stateWithError, fetchContributableNamesRequest())).toEqual( + expect.objectContaining({ + contributableNamesError: null + }) + ) + }) + + it('should set the contributable names action as loading', () => { + const stateWithoutLoading = { + ...state, + loading: [] + } + const action = fetchContributableNamesRequest() + expect(ensReducer(stateWithoutLoading, fetchContributableNamesRequest())).toEqual( + expect.objectContaining({ + loading: [action] + }) + ) + }) + }) + + describe('when handling fetch contributable names success action', () => { + let ens: ENS + beforeEach(() => { + state = { + ...state, + loading: [fetchContributableNamesRequest()] + } + + ens = { name: 'test.dcl.eth', subdomain: 'test.dcl.eth' } as ENS + }) + + it('should add the contributable names to the state', () => { + expect(ensReducer(state, fetchContributableNamesSuccess([ens]))).toEqual( + expect.objectContaining({ + contributableNames: { 'test.dcl.eth': ens } + }) + ) + }) + + it('should remove the loading action from the state', () => { + expect(ensReducer(state, fetchContributableNamesSuccess([ens]))).toEqual( + expect.objectContaining({ + loading: [] + }) + ) + }) + }) + + describe('when handling fetch contributable names failure action', () => { + let error: ENSError + beforeEach(() => { + error = { message: 'Message' } + state = { + ...state, + loading: [fetchContributableNamesRequest()] + } + }) + + it('should add the error to the state', () => { + expect(ensReducer(state, fetchContributableNamesFailure(error))).toEqual( + expect.objectContaining({ + contributableNamesError: error + }) + ) + }) + + it('should reset loading', () => { + expect(ensReducer(state, fetchContributableNamesFailure(error))).toEqual( + expect.objectContaining({ + loading: [] + }) + ) + }) + }) +}) diff --git a/src/modules/ens/reducer.ts b/src/modules/ens/reducer.ts index 842a2e2f1..b235a1a27 100644 --- a/src/modules/ens/reducer.ts +++ b/src/modules/ens/reducer.ts @@ -51,7 +51,13 @@ import { SetENSAddressSuccessAction, setENSAddressSuccess, CLEAR_ENS_ERRORS, - ClearENSErrorsAction + ClearENSErrorsAction, + FETCH_CONTRIBUTABLE_NAMES_SUCCESS, + FetchContributableNamesFailureAction, + FetchContributableNamesRequestAction, + FetchContributableNamesSuccessAction, + FETCH_CONTRIBUTABLE_NAMES_REQUEST, + FETCH_CONTRIBUTABLE_NAMES_FAILURE } from './actions' import { ENS, ENSError } from './types' import { isExternalName } from './utils' @@ -59,15 +65,19 @@ import { isExternalName } from './utils' export type ENSState = { data: Record externalNames: Record + contributableNames: Record loading: LoadingState error: ENSError | null + contributableNamesError: ENSError | null } export const INITIAL_STATE: ENSState = { data: {}, externalNames: {}, + contributableNames: {}, loading: [], - error: null + error: null, + contributableNamesError: null } export type ENSReducerAction = @@ -97,6 +107,9 @@ export type ENSReducerAction = | SetENSAddressSuccessAction | SetENSAddressFailureAction | ClearENSErrorsAction + | FetchContributableNamesFailureAction + | FetchContributableNamesRequestAction + | FetchContributableNamesSuccessAction export function ensReducer(state: ENSState = INITIAL_STATE, action: ENSReducerAction): ENSState { switch (action.type) { @@ -116,6 +129,13 @@ export function ensReducer(state: ENSState = INITIAL_STATE, action: ENSReducerAc loading: loadingReducer(state.loading, action) } } + case FETCH_CONTRIBUTABLE_NAMES_REQUEST: { + return { + ...state, + contributableNamesError: null, + loading: loadingReducer(state.loading, action) + } + } case FETCH_ENS_LIST_SUCCESS: { return { ...state, @@ -209,6 +229,22 @@ export function ensReducer(state: ENSState = INITIAL_STATE, action: ENSReducerAc } } } + case FETCH_CONTRIBUTABLE_NAMES_SUCCESS: { + const { names } = action.payload + const contributableNamesByDomain = names.reduce((obj, ens) => { + obj[ens.subdomain] = ens + return obj + }, {} as Record) + + return { + ...state, + loading: loadingReducer(state.loading, action), + contributableNames: { + ...state.contributableNames, + ...contributableNamesByDomain + } + } + } case RECLAIM_NAME_FAILURE: case SET_ENS_RESOLVER_FAILURE: case SET_ENS_CONTENT_FAILURE: @@ -223,6 +259,13 @@ export function ensReducer(state: ENSState = INITIAL_STATE, action: ENSReducerAc error: { ...action.payload.error } } } + case FETCH_CONTRIBUTABLE_NAMES_FAILURE: { + return { + ...state, + loading: loadingReducer(state.loading, action), + contributableNamesError: { ...action.payload.error } + } + } case CLEAR_ENS_ERRORS: { return { ...state, diff --git a/src/modules/ens/sagas.spec.ts b/src/modules/ens/sagas.spec.ts index f72b6a2cb..a5c9fe639 100644 --- a/src/modules/ens/sagas.spec.ts +++ b/src/modules/ens/sagas.spec.ts @@ -17,6 +17,9 @@ import { WorldInfo, WorldsAPI } from 'lib/api/worlds' import { ENSApi } from 'lib/api/ens' import { getWallet } from 'modules/wallet/utils' import { + fetchContributableNamesFailure, + fetchContributableNamesRequest, + fetchContributableNamesSuccess, fetchENSWorldStatusFailure, fetchENSWorldStatusRequest, fetchENSWorldStatusSuccess, @@ -28,10 +31,11 @@ import { setENSAddressSuccess } from './actions' import { ensSaga } from './sagas' -import { ENS, ENSError, ENSOrigin, WorldStatus } from './types' +import { ContributableDomain, ENS, ENSError, ENSOrigin, WorldStatus } from './types' import { getENSBySubdomain, getExternalNames } from './selectors' import { addWorldStatusToEachENS } from './utils' import { Authorization } from 'lib/api/auth' +import { marketplace } from 'lib/api/marketplace' jest.mock('@dcl/builder-client') @@ -306,3 +310,111 @@ describe('when handling the set ens address request', () => { .silentRun() }) }) + +describe('when handling the fetching of contributable names', () => { + describe('when fetchContributableDomains throws an error', () => { + let ensError: ENSError + let error: Error + + beforeEach(() => { + error = new Error('Some Error') + ensError = { message: error.message } + }) + + it('should dispatch an error action with the error', async () => { + await expectSaga(ensSaga, builderClient, ensApi, worldsAPIContent) + .provide([[call([worldsAPIContent, 'fetchContributableDomains']), throwError(error)]]) + .put(fetchContributableNamesFailure(ensError)) + .dispatch(fetchContributableNamesRequest()) + .silentRun() + }) + }) + + describe('when fetchContributableDomains returns a list of contributable names', () => { + let contributableName: ContributableDomain + let names: ENS[] + let namesWithWorldStatus: ENS[] + + beforeEach(() => { + contributableName = { name: 'test.dcl.eth', size: '120', owner: '0x123', user_permissions: ['deployment'] } + names = [ + { + name: contributableName.name, + subdomain: contributableName.name, + nftOwnerAddress: contributableName.owner, + content: '', + ensOwnerAddress: '', + resolver: '', + tokenId: '', + userPermissions: contributableName.user_permissions, + size: contributableName.size + } + ] + + namesWithWorldStatus = [{ ...names[0], worldStatus: { healthy: true, scene: { urn: 'urn', entityId: 'id' } } }] + }) + + describe('and there are no banned names', () => { + it('should dispatch a success action with the contributable names', async () => { + await expectSaga(ensSaga, builderClient, ensApi, worldsAPIContent) + .provide([ + [call([worldsAPIContent, 'fetchContributableDomains']), [contributableName]], + [call([lists, 'fetchBannedNames']), []], + [call([marketplace, 'fetchENSOwnerByDomain'], ['test']), { test: '0x123' }], + [call([ensApi, 'fetchExternalENSOwners'], []), {}], + [call(addWorldStatusToEachENS, names), namesWithWorldStatus] + ]) + .put(fetchWorldDeploymentsRequest(['test.dcl.eth'])) + .put(fetchContributableNamesSuccess(namesWithWorldStatus)) + .dispatch(fetchContributableNamesRequest()) + .silentRun() + }) + }) + + describe('and fetching the names owners throws an error', () => { + let ensError: ENSError + let error: Error + + beforeEach(() => { + error = new Error('Some Error') + ensError = { message: error.message } + }) + + it('should dispatch an error action with the error', async () => { + await expectSaga(ensSaga, builderClient, ensApi, worldsAPIContent) + .provide([ + [call([worldsAPIContent, 'fetchContributableDomains']), [contributableName]], + [call([lists, 'fetchBannedNames']), []], + [call([marketplace, 'fetchENSOwnerByDomain'], ['test']), throwError(error)], + [call([ensApi, 'fetchExternalENSOwners'], []), {}] + ]) + .put(fetchContributableNamesFailure(ensError)) + .dispatch(fetchContributableNamesRequest()) + .silentRun() + }) + }) + + describe('and fetching the external ens owners throws an error', () => { + let ensError: ENSError + let error: Error + + beforeEach(() => { + error = new Error('Some Error') + ensError = { message: error.message } + }) + + it('should dispatch an error action with the error', async () => { + await expectSaga(ensSaga, builderClient, ensApi, worldsAPIContent) + .provide([ + [call([worldsAPIContent, 'fetchContributableDomains']), [contributableName]], + [call([lists, 'fetchBannedNames']), []], + [call([marketplace, 'fetchENSOwnerByDomain'], ['test']), { test: '0x123' }], + [call([ensApi, 'fetchExternalENSOwners'], []), throwError(error)] + ]) + .put(fetchContributableNamesFailure(ensError)) + .dispatch(fetchContributableNamesRequest()) + .silentRun() + }) + }) + }) +}) diff --git a/src/modules/ens/sagas.ts b/src/modules/ens/sagas.ts index 858d98538..0e599f810 100644 --- a/src/modules/ens/sagas.ts +++ b/src/modules/ens/sagas.ts @@ -60,10 +60,13 @@ import { SetENSAddressRequestAction, setENSAddressSuccess, setENSAddressFailure, - SET_ENS_ADDRESS_REQUEST + SET_ENS_ADDRESS_REQUEST, + FETCH_CONTRIBUTABLE_NAMES_REQUEST, + fetchContributableNamesSuccess, + fetchContributableNamesFailure } from './actions' import { getENSBySubdomain, getExternalNames } from './selectors' -import { ENS, ENSOrigin, ENSError } from './types' +import { ENS, ENSOrigin, ENSError, ContributableDomain } from './types' import { addWorldStatusToEachENS, getLandRedirectionHashes, isExternalName } from './utils' export function* ensSaga(builderClient: BuilderClient, ensApi: ENSApi, worldsAPIContent: WorldsAPI) { @@ -77,6 +80,7 @@ export function* ensSaga(builderClient: BuilderClient, ensApi: ENSApi, worldsAPI yield takeEvery(FETCH_EXTERNAL_NAMES_REQUEST, handleFetchExternalNamesRequest) yield takeEvery(CONNECT_WALLET_SUCCESS, handleConnectWallet) yield takeEvery(SET_ENS_ADDRESS_REQUEST, handleSetENSAddressRequest) + yield takeEvery(FETCH_CONTRIBUTABLE_NAMES_REQUEST, handleFetchContributableNamesRequest) function* handleFetchLandsSuccess() { yield put(fetchENSListRequest()) @@ -497,6 +501,53 @@ export function* ensSaga(builderClient: BuilderClient, ensApi: ENSApi, worldsAPI } } + function* handleFetchContributableNamesRequest() { + try { + let names: ContributableDomain[] = yield call([worldsAPIContent, 'fetchContributableDomains']) + + const bannedNames: string[] = yield call([lists, 'fetchBannedNames']) + const bannedNamesSet = new Set(bannedNames.map(x => x.toLowerCase())) + + names = names.filter(({ name }) => name.split('.').every(nameSegment => !bannedNamesSet.has(nameSegment))) + const ensDomains: string[] = [] + const nameDomains: string[] = [] + names.forEach(({ name }) => { + if (name.includes('.dcl.eth')) { + nameDomains.push(name.replace('.dcl.eth', '')) + } else { + ensDomains.push(name) + } + }) + + const [ownerByNameDomain, ownerByEnsDomain]: [Record, Record] = yield all([ + call([marketplace, 'fetchENSOwnerByDomain'], nameDomains), + call([ensApi, 'fetchExternalENSOwners'], ensDomains) + ]) + + const enss: ENS[] = names.map(({ name, user_permissions, size }) => { + const nameDomain = name.replace('.dcl.eth', '') + return { + subdomain: name, + nftOwnerAddress: name.includes('dcl.eth') ? ownerByNameDomain[nameDomain] : ownerByEnsDomain[name], + content: '', + ensOwnerAddress: '', + name, + resolver: '', + tokenId: '', + userPermissions: user_permissions, + size + } + }) + + const ensWithWorldStatus: ENS[] = yield call(addWorldStatusToEachENS, enss) + yield put(fetchWorldDeploymentsRequest(ensWithWorldStatus.filter(ens => ens.worldStatus).map(ens => ens.subdomain))) + yield put(fetchContributableNamesSuccess(ensWithWorldStatus)) + } catch (error) { + const ensError: ENSError = { message: isErrorWithMessage(error) ? error.message : 'Unknown error' } + yield put(fetchContributableNamesFailure(ensError)) + } + } + function* handleConnectWallet(action: ConnectWalletSuccessAction) { yield put(fetchExternalNamesRequest(action.payload.wallet.address)) } diff --git a/src/modules/ens/selectors.spec.ts b/src/modules/ens/selectors.spec.ts index 2f6087244..52203f25b 100644 --- a/src/modules/ens/selectors.spec.ts +++ b/src/modules/ens/selectors.spec.ts @@ -1,9 +1,17 @@ import { RootState } from 'modules/common/types' import { TransactionState } from 'decentraland-dapps/dist/modules/transaction/reducer' import { Transaction, TransactionStatus } from 'decentraland-dapps/dist/modules/transaction/types' -import { getExternalNames, getExternalNamesForConnectedWallet, getExternalNamesForWallet, isWaitingTxSetAddress } from './selectors' +import { + getContributableNames, + getContributableNamesError, + getExternalNames, + getExternalNamesForConnectedWallet, + getExternalNamesForWallet, + isWaitingTxSetAddress +} from './selectors' import { ENSState } from './reducer' import { SET_ENS_ADDRESS_SUCCESS, SET_ENS_CONTENT_SUCCESS } from './actions' +import { ENS } from './types' let state: RootState let wallet1: string @@ -184,3 +192,65 @@ describe('when using isWaitingTxSetAddress selector', () => { }) }) }) + +describe('when using getContributableNames selector', () => { + let contributableNames: ENSState['contributableNames'] + beforeEach(() => { + contributableNames = { + 'test.dcl.eth': { + name: 'test.dcl.eth' + } as ENS, + 'test2.eth': { + name: 'test2.eth' + } as ENS + } + state = { + ...state, + ens: { + ...state.ens, + contributableNames + } + } + }) + + it('should return a record of contributable names', () => { + expect(getContributableNames(state)).toEqual(contributableNames) + }) +}) + +describe('when using the getContributableNamesError selector', () => { + let contributableNamesError: ENSState['contributableNamesError'] + describe('when there was an error laoding the contributable names', () => { + beforeEach(() => { + contributableNamesError = { message: 'Test error' } + state = { + ...state, + ens: { + ...state.ens, + contributableNamesError + } + } + }) + + it('should return a record of the contributable names', () => { + expect(getContributableNamesError(state)).toEqual(contributableNamesError) + }) + }) + + describe('when the contributable names were loaded successfully', () => { + beforeEach(() => { + contributableNamesError = null + state = { + ...state, + ens: { + ...state.ens, + contributableNamesError + } + } + }) + + it('should return a record of the contributable names', () => { + expect(getContributableNamesError(state)).toEqual(contributableNamesError) + }) + }) +}) diff --git a/src/modules/ens/selectors.ts b/src/modules/ens/selectors.ts index 62cab6831..7f8efb646 100644 --- a/src/modules/ens/selectors.ts +++ b/src/modules/ens/selectors.ts @@ -21,6 +21,8 @@ import { getDomainFromName } from './utils' export const getState = (state: RootState) => state.ens export const getData = (state: RootState) => getState(state).data export const getExternalNames = (state: RootState) => getState(state).externalNames +export const getContributableNames = (state: RootState) => getState(state).contributableNames +export const getContributableNamesError = (state: RootState) => getState(state).contributableNamesError export const getError = (state: RootState) => getState(state).error export const getLoading = (state: RootState) => getState(state).loading @@ -34,6 +36,10 @@ export const getExternalNamesList = createSelector(getExternalNames, externalNam return Object.values(externalNames) }) +export const getContributableNamesList = createSelector(getContributableNames, contributableNames => { + return Object.values(contributableNames) +}) + export const getExternalNamesForConnectedWallet = createSelector(getExternalNames, getAddress, (externalNames, address = '') => { return Object.values(externalNames).filter(externalName => isEqual(externalName.nftOwnerAddress, address)) }) diff --git a/src/modules/ens/types.ts b/src/modules/ens/types.ts index aa87e373d..9aabc26c6 100644 --- a/src/modules/ens/types.ts +++ b/src/modules/ens/types.ts @@ -22,6 +22,8 @@ export type ENS = { worldStatus?: WorldStatus | null ensAddressRecord?: string + userPermissions?: string[] + size?: string } export type ENSError = { @@ -43,3 +45,10 @@ export type WorldStatus = { entityId: string } } + +export type ContributableDomain = { + name: string + user_permissions: string[] + owner: string + size: string +} diff --git a/src/modules/features/selectors.spec.ts b/src/modules/features/selectors.spec.ts index bb83cdb19..f0515502a 100644 --- a/src/modules/features/selectors.spec.ts +++ b/src/modules/features/selectors.spec.ts @@ -6,7 +6,8 @@ import { getIsMaintenanceEnabled, getIsPublishCollectionsWertEnabled, getIsVrmOptOutEnabled, - getIsWearableUtilityEnabled + getIsWearableUtilityEnabled, + getIsWorldContributorEnabled } from './selectors' import { FeatureName } from './types' @@ -63,7 +64,8 @@ const ffSelectors = [ { selector: getIsCreateSceneOnlySDK7Enabled, app: ApplicationName.BUILDER, feature: FeatureName.CREATE_SCENE_ONLY_SDK7 }, { 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: getIsWearableUtilityEnabled, app: ApplicationName.DAPPS, feature: FeatureName.WEARABLE_UTILITY }, + { selector: getIsWorldContributorEnabled, app: ApplicationName.BUILDER, feature: FeatureName.WORLD_CONTRIBUTOR } ] ffSelectors.forEach(({ selector, app, feature }) => { diff --git a/src/modules/features/selectors.ts b/src/modules/features/selectors.ts index 4655406f4..3f67aa35a 100644 --- a/src/modules/features/selectors.ts +++ b/src/modules/features/selectors.ts @@ -53,3 +53,11 @@ export const getIsWearableUtilityEnabled = (state: RootState) => { return false } } + +export const getIsWorldContributorEnabled = (state: RootState) => { + try { + return getIsFeatureEnabled(state, ApplicationName.BUILDER, FeatureName.WORLD_CONTRIBUTOR) + } catch (e) { + return false + } +} diff --git a/src/modules/features/types.ts b/src/modules/features/types.ts index c3e5f1686..d31ac9a7b 100644 --- a/src/modules/features/types.ts +++ b/src/modules/features/types.ts @@ -6,5 +6,6 @@ export enum FeatureName { ENS_ADDRESS = 'ens-address', PUBLISH_COLLECTIONS_WERT = 'publish-collections-wert', VRM_OPTOUT = 'vrm-optout', - WEARABLE_UTILITY = 'wearable-utility' + WEARABLE_UTILITY = 'wearable-utility', + WORLD_CONTRIBUTOR = 'world-contributor' } diff --git a/src/modules/translation/languages/en.json b/src/modules/translation/languages/en.json index ecfe82f41..7c09244ad 100644 --- a/src/modules/translation/languages/en.json +++ b/src/modules/translation/languages/en.json @@ -715,9 +715,11 @@ }, "worlds_list_page": { "title": "Worlds", + "error_title": "An error occur while loading worlds", "table": { "name": "NAME", "url": "URL", + "owner": "Owner", "published_scene": "Published Scene", "publish_scene": "Publish Scene", "unpublish_scene": "Unpublish Scene", @@ -730,7 +732,17 @@ "actions": "Actions", "empty_url": "To activate this world you need to publish a scene", "size": "Size Mb", - "scene_published_outside_builder": "The scene project may have been deleted or published using the SDK." + "scene_published_outside_builder": "The scene project may have been deleted or published using the SDK.", + "user_permissions": { + "deployment": "Deploy", + "streaming": "Stream" + }, + "permissions": "Permissions" + }, + "empty_contributor_list": { + "title": "Nothing Here Yet", + "description": "Currently, you don't have collaborator permissions to any world. You can get your own free world when you own a NAME.", + "cta": "Claim Name" }, "empty_list": { "title": "Get a free World when you own a NAME", @@ -742,7 +754,8 @@ }, "name_tabs": { "dcl_names": "Decentraland NAMEs", - "ens_names": "ENS Domains" + "ens_names": "ENS Domains", + "contributor_names": "Contributor" }, "worlds_storage": { "used": "Space Used", diff --git a/src/modules/translation/languages/es.json b/src/modules/translation/languages/es.json index 924c08e68..1c3cbf0e5 100644 --- a/src/modules/translation/languages/es.json +++ b/src/modules/translation/languages/es.json @@ -716,9 +716,11 @@ "error": "Algo salió mal. Por favor, vuelva a intentarlo." }, "worlds_list_page": { + "error_title": "Se produjo un error al cargar los mundos", "table": { "name": "NOMBRE", "url": "URL", + "owner": "Dueño", "published_scene": "Escena Publicada", "publish_scene": "Publicar Escena", "unpublish_scene": "Despublicar Escene", @@ -731,7 +733,17 @@ "actions": "Acciones", "empty_url": "Para activar este mundo, necesitas publicar una escena", "size": "Peso Mb", - "scene_published_outside_builder": "Es posible que esta escena se haya eliminado o fuera publicada mediante el SDK." + "scene_published_outside_builder": "Es posible que esta escena se haya eliminado o fuera publicada mediante el SDK.", + "user_permissions": { + "deployment": "Publicar", + "streaming": "Transmitir" + }, + "permissions": "Permisos" + }, + "empty_contributor_list": { + "title": "Nada aquí todavía", + "description": "Actualmente, no tiene permisos de colaborador para ningún mundo. Puedes obtener tu propio mundo gratis cuando tienes un NAME.", + "cta": "Reclamar un nuevo NAME" }, "empty_list": { "title": "Obtenga un mundo gratis cuando tenga un NAME", @@ -743,7 +755,8 @@ }, "name_tabs": { "dcl_names": "NAMEs de Decentraland", - "ens_names": "Dominios ENS" + "ens_names": "Dominios ENS", + "contributor_names": "Contribuyente" }, "worlds_storage": { "used": "Espacio utilizado", diff --git a/src/modules/translation/languages/zh.json b/src/modules/translation/languages/zh.json index 6b7e7d1dc..c09fef78e 100644 --- a/src/modules/translation/languages/zh.json +++ b/src/modules/translation/languages/zh.json @@ -718,9 +718,11 @@ "error": "出了问题,请重试。" }, "worlds_list_page": { + "error_title": "加载世界时发生错误", "table": { "name": "姓名", "url": "url", + "owner": "所有者", "published_scene": "出版的场景", "publish_scene": "发布场景", "unpublish_scene": "取消发布场景", @@ -733,7 +735,17 @@ "actions": "动作", "empty_url": "要激活这个世界,您需要发布一个场景", "size": "重量 Mb", - "scene_published_outside_builder": "场景项目可能已被删除或使用 SDK 发布。" + "scene_published_outside_builder": "场景项目可能已被删除或使用 SDK 发布。", + "user_permissions": { + "deployment": "部署", + "streaming": "溪流" + }, + "permissions": "权限" + }, + "empty_contributor_list": { + "title": "这里什么都没有", + "description": "目前,您没有任何世界的合作许可。当您拥有一个名字时,您可以获得自己的自由世界。", + "cta": "索赔名称" }, "empty_list": { "title": "当您拥有一个名字时获得一个自由的世界", @@ -745,7 +757,8 @@ }, "name_tabs": { "dcl_names": "Decentraland 名称", - "ens_names": "ENS 域名" + "ens_names": "ENS 域名", + "contributor_names": "贡献者" }, "worlds_storage": { "used": "已使用空间",