diff --git a/newIDE/app/public/res/features/games-dashboard.svg b/newIDE/app/public/res/features/games-dashboard.svg new file mode 100644 index 000000000000..0563c7167343 --- /dev/null +++ b/newIDE/app/public/res/features/games-dashboard.svg @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/newIDE/app/src/GameDashboard/GameCard.js b/newIDE/app/src/GameDashboard/GameCard.js index a0c913a1ad47..dbb8b6f15578 100644 --- a/newIDE/app/src/GameDashboard/GameCard.js +++ b/newIDE/app/src/GameDashboard/GameCard.js @@ -27,7 +27,7 @@ import { type Game, } from '../Utils/GDevelopServices/Game'; import Window from '../Utils/Window'; -import { type GameDetailsTab } from './GameDetailsDialog'; +import { type GameDetailsTab } from './GameDetails'; import { showErrorBox } from '../UI/Messages/MessageBox'; import BackgroundText from '../UI/BackgroundText'; import Card from '../UI/Card'; diff --git a/newIDE/app/src/GameDashboard/GameDetails.js b/newIDE/app/src/GameDashboard/GameDetails.js new file mode 100644 index 000000000000..7d784b72e501 --- /dev/null +++ b/newIDE/app/src/GameDashboard/GameDetails.js @@ -0,0 +1,609 @@ +// @flow +import { Trans, t } from '@lingui/macro'; +import { I18n } from '@lingui/react'; +import { type I18n as I18nType } from '@lingui/core'; +import * as React from 'react'; +import FlatButton from '../UI/FlatButton'; +import { Line, Spacer } from '../UI/Grid'; +import { + type Game, + updateGame, + deleteGame, + getPublicGame, + setGameUserAcls, + setGameSlug, + getAclsFromUserIds, + getCategoryName, +} from '../Utils/GDevelopServices/Game'; +import { type TabOptions } from '../UI/Tabs'; +import { ColumnStackLayout, ResponsiveLineStackLayout } from '../UI/Layout'; +import Text from '../UI/Text'; +import AuthenticatedUserContext from '../Profile/AuthenticatedUserContext'; +import PlaceholderError from '../UI/PlaceholderError'; +import SelectField from '../UI/SelectField'; +import SelectOption from '../UI/SelectOption'; +import Chip from '@material-ui/core/Chip'; +import Builds from '../ExportAndShare/Builds'; +import AlertMessage from '../UI/AlertMessage'; +import RaisedButton from '../UI/RaisedButton'; +import { type PublicGame } from '../Utils/GDevelopServices/Game'; +import PlaceholderLoader from '../UI/PlaceholderLoader'; +import { + PublicGamePropertiesDialog, + type PartialGameChange, +} from './PublicGamePropertiesDialog'; +import TextField from '../UI/TextField'; +import KeyboardIcon from '@material-ui/icons/Keyboard'; +import SportsEsportsIcon from '@material-ui/icons/SportsEsports'; +import SmartphoneIcon from '@material-ui/icons/Smartphone'; +import Crown from '../UI/CustomSvgIcons/Crown'; +import { showErrorBox } from '../UI/Messages/MessageBox'; +import LeaderboardAdmin from './LeaderboardAdmin'; +import { GameAnalyticsPanel } from './GameAnalyticsPanel'; +import GameFeedback from './Feedbacks/GameFeedback'; +import { GameMonetization } from './Monetization/GameMonetization'; +import RouterContext from '../MainFrame/RouterContext'; +import { sendGameDetailsOpened } from '../Utils/Analytics/EventSender'; +import useAlertDialog from '../UI/Alert/useAlertDialog'; + +export type GameDetailsTab = + | 'details' + | 'builds' + | 'feedback' + | 'analytics' + | 'leaderboards' + | 'monetization'; + +export const gameDetailsTabs: TabOptions = [ + { + value: 'details', + label: Details, + }, + { + value: 'builds', + label: Builds, + }, + { + value: 'feedback', + label: Feedback, + }, + { + value: 'analytics', + label: Analytics, + }, + { + value: 'leaderboards', + label: Leaderboards, + }, + { + value: 'monetization', + label: Monetization, + }, +]; + +type Props = {| + game: Game, + project: ?gdProject, + onGameUpdated: (updatedGame: Game) => void, + onGameDeleted: () => void, + onLoading: boolean => void, + currentTab: GameDetailsTab, + setCurrentTab: GameDetailsTab => void, + analyticsSource: 'profile' | 'homepage' | 'projectManager', +|}; + +const GameDetails = ({ + game, + project, + onGameUpdated, + onGameDeleted, + onLoading, + currentTab, + setCurrentTab, + analyticsSource, +}: Props) => { + const { routeArguments, removeRouteArguments } = React.useContext( + RouterContext + ); + const { getAuthorizationHeader, profile } = React.useContext( + AuthenticatedUserContext + ); + const [ + gameUnregisterErrorText, + setGameUnregisterErrorText, + ] = React.useState(null); + const [isGameUpdating, setIsGameUpdating] = React.useState(false); + const { showConfirmation, showAlert } = useAlertDialog(); + + const authenticatedUser = React.useContext(AuthenticatedUserContext); + const [publicGame, setPublicGame] = React.useState(null); + const [publicGameError, setPublicGameError] = React.useState(null); + const [ + isPublicGamePropertiesDialogOpen, + setIsPublicGamePropertiesDialogOpen, + ] = React.useState(false); + + // If a game dashboard tab is specified, switch to it. + React.useEffect( + () => { + if (routeArguments['games-dashboard-tab']) { + // Ensure that the tab is valid. + const gameDetailsTab = gameDetailsTabs.find( + gameDetailsTab => + gameDetailsTab.value === routeArguments['games-dashboard-tab'] + ); + if (gameDetailsTab) setCurrentTab(gameDetailsTab.value); + // Cleanup once open, to ensure it is not opened again. + removeRouteArguments(['games-dashboard-tab']); + } + }, + [routeArguments, removeRouteArguments, setCurrentTab] + ); + + const loadPublicGame = React.useCallback( + async () => { + setPublicGameError(null); + try { + const publicGameResponse = await getPublicGame(game.id); + setPublicGame(publicGameResponse); + } catch (err) { + console.error(`Unable to load the game:`, err); + setPublicGameError(err); + } + }, + [game] + ); + + React.useEffect( + () => { + loadPublicGame(); + }, + [loadPublicGame] + ); + + React.useEffect( + () => { + sendGameDetailsOpened({ from: analyticsSource }); + }, + [analyticsSource] + ); + + const handleGameUpdated = React.useCallback( + (updatedGame: Game) => { + // Set Public Game to null to show the loader. + // It will be refetched thanks to loadPublicGame, because Game is updated. + setPublicGame(null); + onGameUpdated(updatedGame); + }, + [onGameUpdated] + ); + + const updateGameFromProject = async ( + partialGameChange: PartialGameChange, + i18n: I18nType + ): Promise => { + if (!project || !profile) return false; + const { id } = profile; + + const ownerIds = partialGameChange.ownerIds; + if (!ownerIds || !ownerIds.length) { + await showAlert({ + title: t`Select an owner`, + message: t`You must select at least one user to be the owner of the game.`, + }); + return false; + } + + try { + setIsGameUpdating(true); + const gameId = project.getProjectUuid(); + const updatedGame = await updateGame(getAuthorizationHeader, id, gameId, { + authorName: project.getAuthor() || 'Unspecified publisher', + gameName: project.getName() || 'Untitled game', + categories: project.getCategories().toJSArray() || [], + description: project.getDescription() || '', + playWithKeyboard: project.isPlayableWithKeyboard(), + playWithGamepad: project.isPlayableWithGamepad(), + playWithMobile: project.isPlayableWithMobile(), + orientation: project.getOrientation(), + discoverable: partialGameChange.discoverable, + }); + if ( + partialGameChange.userSlug && + partialGameChange.gameSlug && + partialGameChange.userSlug === profile.username + ) { + try { + await setGameSlug( + getAuthorizationHeader, + id, + gameId, + partialGameChange.userSlug, + partialGameChange.gameSlug + ); + } catch (error) { + console.error( + 'Unable to update the game slug:', + error.response || error.message + ); + showErrorBox({ + message: + i18n._( + t`Unable to update the game slug. A slug must be 6 to 30 characters long and only contains letters, digits or dashes.` + ) + + ' ' + + i18n._(t`Verify your internet connection or try again later.`), + rawError: error, + errorId: 'game-slug-update-error', + }); + setIsGameUpdating(false); + return false; + } + } + try { + const authorAcls = getAclsFromUserIds( + project.getAuthorIds().toJSArray() + ); + const ownerAcls = getAclsFromUserIds(ownerIds); + await setGameUserAcls(getAuthorizationHeader, id, gameId, { + ownership: ownerAcls, + author: authorAcls, + }); + } catch (error) { + console.error( + 'Unable to update the game owners or authors:', + error.response || error.message + ); + showErrorBox({ + message: + i18n._( + t`Unable to update the game owners or authors. Have you removed yourself from the owners?` + ) + + ' ' + + i18n._(t`Verify your internet connection or try again later.`), + rawError: error, + errorId: 'game-acls-update-error', + }); + setIsGameUpdating(false); + return false; + } + handleGameUpdated(updatedGame); + } catch (error) { + console.error( + 'Unable to update the game:', + error.response || error.message + ); + showErrorBox({ + message: + i18n._(t`Unable to update the game details.`) + + ' ' + + i18n._(t`Verify your internet connection or try again later.`), + rawError: error, + errorId: 'game-details-update-error', + }); + setIsGameUpdating(false); + return false; + } + + setIsGameUpdating(false); + return true; + }; + + const unregisterGame = React.useCallback( + async (i18n: I18nType) => { + if (!profile) return; + const { id } = profile; + setGameUnregisterErrorText(null); + onLoading(true); + try { + setIsGameUpdating(true); + await deleteGame(getAuthorizationHeader, id, game.id); + onGameDeleted(); + } catch (error) { + console.error('Unable to delete the game:', error); + if ( + error.response && + error.response.data && + error.response.data.code === 'game-deletion/leaderboards-exist' + ) { + setGameUnregisterErrorText( + i18n._( + t`You cannot unregister a game that has active leaderboards. To delete them, go in the Leaderboards tab, and delete them one by one.` + ) + ); + } + } finally { + setIsGameUpdating(false); + onLoading(false); + } + }, + [onLoading, game.id, profile, onGameDeleted, getAuthorizationHeader] + ); + + const unpublishGame = React.useCallback( + async () => { + if (!profile) return; + + const { id } = profile; + try { + setIsGameUpdating(true); + const updatedGame = await updateGame( + getAuthorizationHeader, + id, + game.id, + { + publicWebBuildId: null, + } + ); + handleGameUpdated(updatedGame); + } catch (err) { + console.error('Unable to update the game', err); + } finally { + setIsGameUpdating(false); + } + }, + [game, getAuthorizationHeader, profile, handleGameUpdated] + ); + + const onClickUnregister = React.useCallback( + async (i18n: I18nType) => { + const answer = await showConfirmation({ + title: t`Unregister game`, + message: t`Are you sure you want to unregister this game?${'\n\n'}It will disappear from your games dashboard and you won't get access to analytics, unless you register it again.`, + }); + + if (!answer) return; + + unregisterGame(i18n); + }, + [unregisterGame, showConfirmation] + ); + + const onClickUnpublish = React.useCallback( + async (i18n: I18nType) => { + const answer = await showConfirmation({ + title: t`Unpublish game`, + message: t`Are you sure you want to unpublish this game?${'\n\n'}This will make your gd.games unique game URL not accessible anymore.${'\n\n'}You can decide at any time to publish it again.`, + }); + + if (!answer) return; + + unpublishGame(); + }, + [unpublishGame, showConfirmation] + ); + + const authorUsernames = + publicGame && + publicGame.authors.map(author => author.username).filter(Boolean); + + const ownerUsernames = + publicGame && + publicGame.owners.map(owner => owner.username).filter(Boolean); + + const isGameOpenedAsProject = + !!project && project.getProjectUuid() === game.id; + + return ( + + {({ i18n }) => ( + <> + + {currentTab === 'leaderboards' ? ( + + ) : null} + {currentTab === 'details' ? ( + publicGameError ? ( + + There was an issue getting the game details.{' '} + + Verify your internet connection or try again later. + + + ) : !publicGame ? ( + + ) : ( + + {!isGameOpenedAsProject && ( + + + In order to update these details you have to open the + game's project. + + + )} + + + {authorUsernames && ( + <> + + Authors: + + + {authorUsernames.map((username, index) => ( + + + + ) : ( + undefined + ) + } + className="notranslate" + label={username} + color={index === 0 ? 'primary' : 'default'} + /> + + ))} + + + )} + + + + + Created on {i18n.date(game.createdAt * 1000)} + + + + + {(publicGame.playWithKeyboard || + publicGame.playWithGamepad || + publicGame.playWithMobile || + publicGame.categories) && ( + + + {publicGame.categories && + !!publicGame.categories.length && ( + <> + + Genres: + + + {publicGame.categories.map( + (category, index) => ( + + + + + ) + )} + + + )} + + + {publicGame.playWithKeyboard && } + {publicGame.playWithGamepad && } + {publicGame.playWithMobile && } + + + )} + Game name} + floatingLabelFixed={true} + /> + Game description} + floatingLabelFixed={true} + translatableHintText={t`No description set.`} + multiline + rows={5} + /> + Device orientation (for mobile) + } + value={publicGame.orientation} + > + + + + + + onClickUnregister(i18n)} + label={Unregister this game} + disabled={isGameUpdating} + /> + {publicGame.publicWebBuildId && ( + onClickUnpublish(i18n)} + label={Unpublish from gd.games} + disabled={isGameUpdating} + /> + )} + setIsPublicGamePropertiesDialogOpen(true)} + label={Edit game details} + disabled={!isGameOpenedAsProject || isGameUpdating} + /> + + {gameUnregisterErrorText ? ( + + {gameUnregisterErrorText} + + ) : null} + + ) + ) : null} + {currentTab === 'builds' ? ( + + ) : null} + {currentTab === 'analytics' ? ( + + ) : null} + {currentTab === 'feedback' ? ( + + ) : null} + {currentTab === 'monetization' ? ( + + + + ) : null} + + {publicGame && project && isPublicGamePropertiesDialogOpen && ( + { + const isGameUpdated = await updateGameFromProject( + partialGameChange, + i18n + ); + if (isGameUpdated) { + setIsPublicGamePropertiesDialogOpen(false); + } + }} + onClose={() => setIsPublicGamePropertiesDialogOpen(false)} + isLoading={isGameUpdating} + i18n={i18n} + /> + )} + + )} + + ); +}; + +export default GameDetails; diff --git a/newIDE/app/src/GameDashboard/GameDetailsDialog.js b/newIDE/app/src/GameDashboard/GameDetailsDialog.js index 950ccbadccc1..103722364a3f 100644 --- a/newIDE/app/src/GameDashboard/GameDetailsDialog.js +++ b/newIDE/app/src/GameDashboard/GameDetailsDialog.js @@ -1,86 +1,16 @@ // @flow -import { Trans, t } from '@lingui/macro'; +import { Trans } from '@lingui/macro'; import { I18n } from '@lingui/react'; -import { type I18n as I18nType } from '@lingui/core'; import * as React from 'react'; import FlatButton from '../UI/FlatButton'; -import { Line, Spacer } from '../UI/Grid'; -import { - type Game, - updateGame, - deleteGame, - getPublicGame, - setGameUserAcls, - setGameSlug, - getAclsFromUserIds, - getCategoryName, -} from '../Utils/GDevelopServices/Game'; +import { type Game } from '../Utils/GDevelopServices/Game'; import Dialog from '../UI/Dialog'; -import { Tabs, type TabOptions } from '../UI/Tabs'; -import { ColumnStackLayout } from '../UI/Layout'; -import Text from '../UI/Text'; -import AuthenticatedUserContext from '../Profile/AuthenticatedUserContext'; -import PlaceholderError from '../UI/PlaceholderError'; -import SelectField from '../UI/SelectField'; -import SelectOption from '../UI/SelectOption'; -import Chip from '@material-ui/core/Chip'; -import Builds from '../ExportAndShare/Builds'; -import AlertMessage from '../UI/AlertMessage'; -import RaisedButton from '../UI/RaisedButton'; -import Window from '../Utils/Window'; +import { Tabs } from '../UI/Tabs'; import HelpButton from '../UI/HelpButton'; -import { type PublicGame } from '../Utils/GDevelopServices/Game'; -import PlaceholderLoader from '../UI/PlaceholderLoader'; -import { - PublicGamePropertiesDialog, - type PartialGameChange, -} from './PublicGamePropertiesDialog'; -import TextField from '../UI/TextField'; -import KeyboardIcon from '@material-ui/icons/Keyboard'; -import SportsEsportsIcon from '@material-ui/icons/SportsEsports'; -import SmartphoneIcon from '@material-ui/icons/Smartphone'; -import Crown from '../UI/CustomSvgIcons/Crown'; -import { showErrorBox, showWarningBox } from '../UI/Messages/MessageBox'; -import LeaderboardAdmin from './LeaderboardAdmin'; -import { GameAnalyticsPanel } from './GameAnalyticsPanel'; -import GameFeedback from './Feedbacks/GameFeedback'; -import { GameMonetization } from './Monetization/GameMonetization'; -import RouterContext from '../MainFrame/RouterContext'; - -export type GameDetailsTab = - | 'details' - | 'builds' - | 'feedback' - | 'analytics' - | 'leaderboards' - | 'monetization'; - -export const gameDetailsTabs: TabOptions = [ - { - value: 'details', - label: Details, - }, - { - value: 'builds', - label: Builds, - }, - { - value: 'feedback', - label: Feedback, - }, - { - value: 'analytics', - label: Analytics, - }, - { - value: 'leaderboards', - label: Leaderboards, - }, - { - value: 'monetization', - label: Monetization, - }, -]; +import GameDetails, { + gameDetailsTabs, + type GameDetailsTab, +} from './GameDetails'; type Props = {| game: Game, @@ -88,6 +18,7 @@ type Props = {| onClose: () => void, onGameUpdated: (updatedGame: Game) => void, onGameDeleted: () => void, + analyticsSource: 'profile' | 'homepage' | 'projectManager', |}; export const GameDetailsDialog = ({ @@ -96,253 +27,10 @@ export const GameDetailsDialog = ({ onClose, onGameUpdated, onGameDeleted, + analyticsSource, }: Props) => { - const { routeArguments, removeRouteArguments } = React.useContext( - RouterContext - ); - const { getAuthorizationHeader, profile } = React.useContext( - AuthenticatedUserContext - ); - const [currentTab, setCurrentTab] = React.useState('details'); const [isLoading, setIsLoading] = React.useState(false); - const [ - gameUnregisterErrorText, - setGameUnregisterErrorText, - ] = React.useState(null); - const [isGameUpdating, setIsGameUpdating] = React.useState(false); - - const authenticatedUser = React.useContext(AuthenticatedUserContext); - const [publicGame, setPublicGame] = React.useState(null); - const [publicGameError, setPublicGameError] = React.useState(null); - const [ - isPublicGamePropertiesDialogOpen, - setIsPublicGamePropertiesDialogOpen, - ] = React.useState(false); - - // If a game dashboard tab is specified, switch to it. - React.useEffect( - () => { - if (routeArguments['games-dashboard-tab']) { - // Ensure that the tab is valid. - const gameDetailsTab = gameDetailsTabs.find( - gameDetailsTab => - gameDetailsTab.value === routeArguments['games-dashboard-tab'] - ); - if (gameDetailsTab) setCurrentTab(gameDetailsTab.value); - // Cleanup once open, to ensure it is not opened again. - removeRouteArguments(['games-dashboard-tab']); - } - }, - [routeArguments, removeRouteArguments] - ); - - const loadPublicGame = React.useCallback( - async () => { - setPublicGameError(null); - try { - const publicGameResponse = await getPublicGame(game.id); - setPublicGame(publicGameResponse); - } catch (err) { - console.error(`Unable to load the game:`, err); - setPublicGameError(err); - } - }, - [game] - ); - - React.useEffect( - () => { - loadPublicGame(); - }, - [loadPublicGame] - ); - - const handleGameUpdated = React.useCallback( - (updatedGame: Game) => { - // Set Public Game to null to show the loader. - // It will be refetched thanks to loadPublicGame, because Game is updated. - setPublicGame(null); - onGameUpdated(updatedGame); - }, - [onGameUpdated] - ); - - const updateGameFromProject = async ( - partialGameChange: PartialGameChange, - i18n: I18nType - ): Promise => { - if (!project || !profile) return false; - const { id } = profile; - - const ownerIds = partialGameChange.ownerIds; - if (!ownerIds || !ownerIds.length) { - showWarningBox( - i18n._( - t`You must select at least one user to be the owner of the game.` - ), - { delayToNextTick: true } - ); - return false; - } - - try { - setIsGameUpdating(true); - const gameId = project.getProjectUuid(); - const updatedGame = await updateGame(getAuthorizationHeader, id, gameId, { - authorName: project.getAuthor() || 'Unspecified publisher', - gameName: project.getName() || 'Untitled game', - categories: project.getCategories().toJSArray() || [], - description: project.getDescription() || '', - playWithKeyboard: project.isPlayableWithKeyboard(), - playWithGamepad: project.isPlayableWithGamepad(), - playWithMobile: project.isPlayableWithMobile(), - orientation: project.getOrientation(), - discoverable: partialGameChange.discoverable, - }); - if ( - partialGameChange.userSlug && - partialGameChange.gameSlug && - partialGameChange.userSlug === profile.username - ) { - try { - await setGameSlug( - getAuthorizationHeader, - id, - gameId, - partialGameChange.userSlug, - partialGameChange.gameSlug - ); - } catch (error) { - console.error( - 'Unable to update the game slug:', - error.response || error.message - ); - showErrorBox({ - message: - i18n._( - t`Unable to update the game slug. A slug must be 6 to 30 characters long and only contains letters, digits or dashes.` - ) + - ' ' + - i18n._(t`Verify your internet connection or try again later.`), - rawError: error, - errorId: 'game-slug-update-error', - }); - setIsGameUpdating(false); - return false; - } - } - try { - const authorAcls = getAclsFromUserIds( - project.getAuthorIds().toJSArray() - ); - const ownerAcls = getAclsFromUserIds(ownerIds); - await setGameUserAcls(getAuthorizationHeader, id, gameId, { - ownership: ownerAcls, - author: authorAcls, - }); - } catch (error) { - console.error( - 'Unable to update the game owners or authors:', - error.response || error.message - ); - showErrorBox({ - message: - i18n._( - t`Unable to update the game owners or authors. Have you removed yourself from the owners?` - ) + - ' ' + - i18n._(t`Verify your internet connection or try again later.`), - rawError: error, - errorId: 'game-acls-update-error', - }); - setIsGameUpdating(false); - return false; - } - handleGameUpdated(updatedGame); - } catch (error) { - console.error( - 'Unable to update the game:', - error.response || error.message - ); - showErrorBox({ - message: - i18n._(t`Unable to update the game details.`) + - ' ' + - i18n._(t`Verify your internet connection or try again later.`), - rawError: error, - errorId: 'game-details-update-error', - }); - setIsGameUpdating(false); - return false; - } - - setIsGameUpdating(false); - return true; - }; - - const unregisterGame = async (i18n: I18nType) => { - if (!profile) return; - const { id } = profile; - setGameUnregisterErrorText(null); - setIsLoading(true); - try { - setIsGameUpdating(true); - await deleteGame(getAuthorizationHeader, id, game.id); - onGameDeleted(); - } catch (error) { - console.error('Unable to delete the game:', error); - if ( - error.response && - error.response.data && - error.response.data.code === 'game-deletion/leaderboards-exist' - ) { - setGameUnregisterErrorText( - i18n._( - t`You cannot unregister a game that has active leaderboards. To delete them, go in the Leaderboards tab, and delete them one by one.` - ) - ); - } - } finally { - setIsGameUpdating(false); - setIsLoading(false); - } - }; - - const unpublishGame = React.useCallback( - async () => { - if (!profile) return; - - const { id } = profile; - try { - setIsGameUpdating(true); - const updatedGame = await updateGame( - getAuthorizationHeader, - id, - game.id, - { - publicWebBuildId: null, - } - ); - handleGameUpdated(updatedGame); - } catch (err) { - console.error('Unable to update the game', err); - } finally { - setIsGameUpdating(false); - } - }, - [game, getAuthorizationHeader, profile, handleGameUpdated] - ); - - const authorUsernames = - publicGame && - publicGame.authors.map(author => author.username).filter(Boolean); - - const ownerUsernames = - publicGame && - publicGame.owners.map(owner => owner.username).filter(Boolean); - - const isGameOpenedAsProject = - !!project && project.getProjectUuid() === game.id; + const [currentTab, setCurrentTab] = React.useState('details'); return ( @@ -381,244 +69,16 @@ export const GameDetailsDialog = ({ /> } > - - {currentTab === 'leaderboards' ? ( - - ) : null} - {currentTab === 'details' ? ( - publicGameError ? ( - - There was an issue getting the game details.{' '} - - Verify your internet connection or try again later. - - - ) : !publicGame ? ( - - ) : ( - - {!isGameOpenedAsProject && ( - - - In order to update these details you have to open the - game's project. - - - )} - - - {authorUsernames && ( - <> - - Authors: - - - {authorUsernames.map((username, index) => ( - - - - ) : ( - undefined - ) - } - className="notranslate" - label={username} - color={index === 0 ? 'primary' : 'default'} - /> - - ))} - - - )} - - - - - Created on {i18n.date(game.createdAt * 1000)} - - - - - {(publicGame.playWithKeyboard || - publicGame.playWithGamepad || - publicGame.playWithMobile || - publicGame.categories) && ( - - - {publicGame.categories && - !!publicGame.categories.length && ( - <> - - Genres: - - - {publicGame.categories.map( - (category, index) => ( - - - - - ) - )} - - - )} - - - {publicGame.playWithKeyboard && } - {publicGame.playWithGamepad && } - {publicGame.playWithMobile && } - - - )} - Game name} - floatingLabelFixed={true} - /> - Game description} - floatingLabelFixed={true} - translatableHintText={t`No description set.`} - multiline - rows={5} - /> - Device orientation (for mobile) - } - value={publicGame.orientation} - > - - - - - - { - const answer = Window.showConfirmDialog( - i18n._( - t`Are you sure you want to unregister this game?` - ) + - '\n\n' + - i18n._( - t`It will disappear from your games dashboard and you won't get access to analytics, unless you register it again.` - ) - ); - - if (!answer) return; - - unregisterGame(i18n); - }} - label={Unregister this game} - disabled={isGameUpdating} - /> - - {publicGame.publicWebBuildId && ( - <> - { - const answer = Window.showConfirmDialog( - 'Are you sure you want to unpublish this game? \n\nThis will make your gd.games unique game URL not accessible anymore. \n\nYou can decide at any time to publish it again.' - ); - - if (!answer) return; - - unpublishGame(); - }} - label={Unpublish from gd.games} - disabled={isGameUpdating} - /> - - - )} - setIsPublicGamePropertiesDialogOpen(true)} - label={Edit game details} - disabled={!isGameOpenedAsProject || isGameUpdating} - /> - - {gameUnregisterErrorText ? ( - - {gameUnregisterErrorText} - - ) : null} - - ) - ) : null} - {currentTab === 'builds' ? ( - - ) : null} - {currentTab === 'analytics' ? ( - - ) : null} - {currentTab === 'feedback' ? ( - - ) : null} - {currentTab === 'monetization' ? ( - - - - ) : null} - - {publicGame && project && isPublicGamePropertiesDialogOpen && ( - { - const isGameUpdated = await updateGameFromProject( - partialGameChange, - i18n - ); - if (isGameUpdated) { - setIsPublicGamePropertiesDialogOpen(false); - } - }} - onClose={() => setIsPublicGamePropertiesDialogOpen(false)} - isLoading={isGameUpdating} - i18n={i18n} - /> - )} + )} diff --git a/newIDE/app/src/GameDashboard/GamesList.js b/newIDE/app/src/GameDashboard/GamesList.js index c844a919dfd6..3b6859a06811 100644 --- a/newIDE/app/src/GameDashboard/GamesList.js +++ b/newIDE/app/src/GameDashboard/GamesList.js @@ -1,58 +1,83 @@ // @flow -import { t, Trans } from '@lingui/macro'; +import { t } from '@lingui/macro'; import * as React from 'react'; import AuthenticatedUserContext from '../Profile/AuthenticatedUserContext'; -import PlaceholderLoader from '../UI/PlaceholderLoader'; -import PlaceholderError from '../UI/PlaceholderError'; -import { - type Game, - getGames, - registerGame, -} from '../Utils/GDevelopServices/Game'; +import { type Game, registerGame } from '../Utils/GDevelopServices/Game'; import { GameCard } from './GameCard'; import { ColumnStackLayout } from '../UI/Layout'; import { GameRegistration } from './GameRegistration'; -import { GameDetailsDialog, type GameDetailsTab } from './GameDetailsDialog'; +import { type GameDetailsTab } from './GameDetails'; import useAlertDialog from '../UI/Alert/useAlertDialog'; import RouterContext from '../MainFrame/RouterContext'; import { extractGDevelopApiErrorStatusAndCode } from '../Utils/GDevelopServices/Errors'; +import SearchBar from '../UI/SearchBar'; +import { useDebounce } from '../Utils/UseDebounce'; +import Fuse from 'fuse.js'; +import { + getFuseSearchQueryForSimpleArray, + sharedFuseConfiguration, +} from '../UI/Search/UseSearchStructuredItem'; + +const getGamesToDisplay = ({ + project, + games, + searchText, + searchClient, +}: {| + project: ?gdProject, + games: Array, + searchText: string, + searchClient: Fuse, +|}): Array => { + const projectUuid = project ? project.getProjectUuid() : null; + const thisGame = games.find(game => !!projectUuid && game.id === projectUuid); + const orderedGames = thisGame + ? [thisGame, ...games.filter(game => game.id !== thisGame.id)] + : games; + if (!searchText) return orderedGames; + const searchResults = searchClient.search( + getFuseSearchQueryForSimpleArray(searchText) + ); + return searchResults.map(result => result.item); +}; type Props = {| project: ?gdProject, + games: Array, + onRefreshGames: () => Promise, + onGameUpdated: Game => void, + onOpenGame: (?Game) => void, |}; -export const GamesList = ({ project }: Props) => { +const GamesList = ({ + project, + games, + onRefreshGames, + onGameUpdated, + onOpenGame, +}: Props) => { const { routeArguments, addRouteArguments, removeRouteArguments, } = React.useContext(RouterContext); - const [error, setError] = React.useState(null); - const [games, setGames] = React.useState>(null); - const { - authenticated, - firebaseUser, - getAuthorizationHeader, - profile, - } = React.useContext(AuthenticatedUserContext); - const [openedGame, setOpenedGame] = React.useState(null); + const { getAuthorizationHeader, profile } = React.useContext( + AuthenticatedUserContext + ); const { showAlert, showConfirmation } = useAlertDialog(); const [isGameRegistering, setIsGameRegistering] = React.useState(false); + const [searchText, setSearchText] = React.useState(''); + const [displayedGames, setDisplayedGames] = React.useState>( + games + ); - const loadGames = React.useCallback( - async () => { - if (!authenticated || !firebaseUser) return; - - try { - setError(null); - const games = await getGames(getAuthorizationHeader, firebaseUser.uid); - setGames(games); - } catch (error) { - console.error('Error while loading user games.', error); - setError(error); - } - }, - [authenticated, firebaseUser, getAuthorizationHeader] + const searchClient = React.useMemo( + () => + new Fuse(games, { + ...sharedFuseConfiguration, + keys: [{ name: 'gameName', weight: 1 }], + }), + [games] ); const onRegisterGame = React.useCallback( @@ -68,7 +93,7 @@ export const GamesList = ({ project }: Props) => { gameName: project.getName() || 'Untitled game', templateSlug: project.getTemplateSlug(), }); - await loadGames(); + await onRefreshGames(); } catch (error) { console.error('Unable to register the game.', error); const extractedStatusAndCode = extractGDevelopApiErrorStatusAndCode( @@ -92,7 +117,7 @@ export const GamesList = ({ project }: Props) => { setIsGameRegistering(false); } }, - [getAuthorizationHeader, profile, project, showAlert, loadGames] + [getAuthorizationHeader, profile, project, showAlert, onRefreshGames] ); React.useEffect( @@ -104,7 +129,7 @@ export const GamesList = ({ project }: Props) => { const game = games.find(game => game.id === initialGameId); removeRouteArguments(['game-id']); if (game) { - setOpenedGame(game); + onOpenGame(game); } else { // If the game is not in the list, then either // - allow to register it, if it's the current project. @@ -139,44 +164,31 @@ export const GamesList = ({ project }: Props) => { showConfirmation, showAlert, project, + onOpenGame, ] ); - React.useEffect( - () => { - loadGames(); - }, - [loadGames] - ); - - if (!authenticated) { - return null; - } - - if (!games && error) { - return ( - { - loadGames(); - }} - > - - Can't load the games. Verify your internet connection or retry later. - - + const getGamesToDisplayDebounced = useDebounce(() => { + setDisplayedGames( + getGamesToDisplay({ + project, + games, + searchText, + searchClient, + }) ); - } + }, 250); - if (!games) { - return ; - } + // Refresh games to display when: + // - search text changes (user input) + // - games change (refresh following an update for instance) + React.useEffect(getGamesToDisplayDebounced, [ + getGamesToDisplayDebounced, + searchText, + games, + ]); const projectUuid = project ? project.getProjectUuid() : null; - const thisGame = games.find(game => !!projectUuid && game.id === projectUuid); - const displayedGames = [ - thisGame, - ...games.filter(game => game !== thisGame), - ].filter(Boolean); return ( @@ -184,42 +196,31 @@ export const GamesList = ({ project }: Props) => { - )} - {displayedGames.map(game => ( - { - addRouteArguments({ 'games-dashboard-tab': tab }); - setOpenedGame(game); - }} - onUpdateGame={loadGames} - /> - ))} - {openedGame && ( - { - setOpenedGame(null); - }} - onGameUpdated={updatedGame => { - setGames( - games.map(game => (game === openedGame ? updatedGame : game)) - ); - setOpenedGame(updatedGame); - }} - onGameDeleted={() => { - setOpenedGame(null); - loadGames(); - }} + onGameRegistered={onRefreshGames} /> )} + {}} + placeholder={t`Search by name`} + /> + {displayedGames && + displayedGames.map(game => ( + { + addRouteArguments({ 'games-dashboard-tab': tab }); + onOpenGame(game); + }} + onUpdateGame={onRefreshGames} + /> + ))} ); }; + +export default GamesList; diff --git a/newIDE/app/src/GameDashboard/PublicGameProperties.js b/newIDE/app/src/GameDashboard/PublicGameProperties.js index 6841ee20e382..a6b0483266b8 100644 --- a/newIDE/app/src/GameDashboard/PublicGameProperties.js +++ b/newIDE/app/src/GameDashboard/PublicGameProperties.js @@ -132,8 +132,12 @@ export function PublicGameProperties({ >([]); const fetchGameCategories = React.useCallback(async () => { - const categories = await getGameCategories(); - setAllGameCategories(categories); + try { + const categories = await getGameCategories(); + setAllGameCategories(categories); + } catch (error) { + console.error('An error occurred while fetching game categories.', error); + } }, []); React.useEffect( diff --git a/newIDE/app/src/GameDashboard/UseGamesList.js b/newIDE/app/src/GameDashboard/UseGamesList.js new file mode 100644 index 000000000000..565dc3bd3d7c --- /dev/null +++ b/newIDE/app/src/GameDashboard/UseGamesList.js @@ -0,0 +1,65 @@ +// @flow + +import * as React from 'react'; +import { getGames, type Game } from '../Utils/GDevelopServices/Game'; +import AuthenticatedUserContext from '../Profile/AuthenticatedUserContext'; + +const useGamesList = () => { + const authenticatedUser = React.useContext(AuthenticatedUserContext); + const gamesFetchingPromise = React.useRef>(null); + const { + authenticated, + firebaseUser, + getAuthorizationHeader, + } = authenticatedUser; + + const [games, setGames] = React.useState>(null); + const [gamesFetchingError, setGamesFetchingError] = React.useState( + null + ); + + const fetchGames = React.useCallback( + async (): Promise => { + if (!authenticated || !firebaseUser) { + setGames(null); + return; + } + if (gamesFetchingPromise.current) return gamesFetchingPromise.current; + + try { + setGamesFetchingError(null); + gamesFetchingPromise.current = getGames( + getAuthorizationHeader, + firebaseUser.uid + ); + const fetchedGames = await gamesFetchingPromise.current; + setGames(fetchedGames); + } catch (error) { + console.error('Error while loading user games.', error); + setGamesFetchingError(error); + } finally { + gamesFetchingPromise.current = null; + } + }, + [authenticated, firebaseUser, getAuthorizationHeader] + ); + + const onGameUpdated = React.useCallback( + (updatedGame: Game) => { + if (!games) return; + setGames( + games.map(game => (game.id === updatedGame.id ? updatedGame : game)) + ); + }, + [games] + ); + + return { + games, + gamesFetchingError, + fetchGames, + onGameUpdated, + }; +}; + +export default useGamesList; diff --git a/newIDE/app/src/InAppTutorial/InAppTutorialTooltipDisplayer.js b/newIDE/app/src/InAppTutorial/InAppTutorialTooltipDisplayer.js index f2169869bddf..71598978c100 100644 --- a/newIDE/app/src/InAppTutorial/InAppTutorialTooltipDisplayer.js +++ b/newIDE/app/src/InAppTutorial/InAppTutorialTooltipDisplayer.js @@ -326,7 +326,6 @@ const InAppTutorialTooltipDisplayer = ({ useIsElementVisibleInScroll(anchorElement, updateVisibility); - const arrowRef = React.useRef(null); const classes = useClasses(); const placement = isMobileScreen && tooltip.mobilePlacement @@ -338,76 +337,71 @@ const InAppTutorialTooltipDisplayer = ({ : '#FAFAFA'; // Grey00 return ( - <> - - {({ TransitionProps }) => ( - <> - - - - setFolded(!folded)} - endTutorial={endTutorial} - /> - {!folded && ( - - )} - - + {({ TransitionProps }) => ( + + + + setFolded(!folded)} + endTutorial={endTutorial} + /> + {!folded && ( + - - - - )} - - + )} + + + + + )} + ); }; diff --git a/newIDE/app/src/MainFrame/EditorContainers/HomePage/BuildSection/ProjectFileListItem.js b/newIDE/app/src/MainFrame/EditorContainers/HomePage/BuildSection/ProjectFileListItem.js index 793a075e3ee9..15301a410bc5 100644 --- a/newIDE/app/src/MainFrame/EditorContainers/HomePage/BuildSection/ProjectFileListItem.js +++ b/newIDE/app/src/MainFrame/EditorContainers/HomePage/BuildSection/ProjectFileListItem.js @@ -28,7 +28,6 @@ import { getRelativeOrAbsoluteDisplayDate } from '../../../../Utils/DateDisplay' import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction'; import IconButton from '../../../../UI/IconButton'; import ThreeDotsMenu from '../../../../UI/CustomSvgIcons/ThreeDotsMenu'; -import RouterContext from '../../../RouterContext'; import { useLongTouch } from '../../../../Utils/UseLongTouch'; import { Avatar, Tooltip } from '@material-ui/core'; import { getGravatarUrl } from '../../../../UI/GravatarUrl'; @@ -206,6 +205,8 @@ type ProjectFileListItemProps = {| onOpenRecentFile: (file: FileMetadataAndStorageProviderName) => void, isWindowWidthMediumOrLarger: boolean, hideDeleteContextMenuAction?: boolean, + onManageGame?: ({ gameId: string }) => void, + canManageGame?: ({ gameId: string }) => boolean, |}; export const ProjectFileListItem = ({ @@ -216,10 +217,11 @@ export const ProjectFileListItem = ({ onOpenRecentFile, isWindowWidthMediumOrLarger, hideDeleteContextMenuAction, + onManageGame, + canManageGame, }: ProjectFileListItemProps) => { const contextMenu = React.useRef(null); const { showDeleteConfirmation, showAlert } = useAlertDialog(); - const { navigateToRoute } = React.useContext(RouterContext); const [pendingProject, setPendingProject] = React.useState(null); const { removeRecentProjectFile } = React.useContext(PreferencesContext); const authenticatedUser = React.useContext(AuthenticatedUserContext); @@ -311,15 +313,13 @@ export const ProjectFileListItem = ({ } const gameId = file.fileMetadata.gameId; - if (gameId) { + if (gameId && onManageGame && canManageGame) { actions = actions.concat([ { type: 'separator' }, { label: i18n._(t`Manage game`), - click: () => - navigateToRoute('games-dashboard', { - 'game-id': gameId, - }), + click: () => onManageGame({ gameId }), + enabled: canManageGame({ gameId }), }, ]); } diff --git a/newIDE/app/src/MainFrame/EditorContainers/HomePage/BuildSection/index.js b/newIDE/app/src/MainFrame/EditorContainers/HomePage/BuildSection/index.js index 0f5458e71617..d37d551cacee 100644 --- a/newIDE/app/src/MainFrame/EditorContainers/HomePage/BuildSection/index.js +++ b/newIDE/app/src/MainFrame/EditorContainers/HomePage/BuildSection/index.js @@ -76,6 +76,8 @@ type Props = {| ) => void, storageProviders: Array, i18n: I18nType, + onManageGame: ({ gameId: string }) => void, + canManageGame: ({ gameId: string }) => boolean, |}; const BuildSection = ({ @@ -90,6 +92,8 @@ const BuildSection = ({ onOpenRecentFile, storageProviders, i18n, + onManageGame, + canManageGame, }: Props) => { const { getRecentProjectFiles } = React.useContext(PreferencesContext); const { exampleShortHeaders } = React.useContext(ExampleStoreContext); @@ -349,6 +353,8 @@ const BuildSection = ({ file.fileMetadata.fileIdentifier ] } + onManageGame={onManageGame} + canManageGame={canManageGame} /> )) ) : ( diff --git a/newIDE/app/src/MainFrame/EditorContainers/HomePage/GetStartedSection/index.js b/newIDE/app/src/MainFrame/EditorContainers/HomePage/GetStartedSection/index.js index ba17e78932ba..389c27e6f18f 100644 --- a/newIDE/app/src/MainFrame/EditorContainers/HomePage/GetStartedSection/index.js +++ b/newIDE/app/src/MainFrame/EditorContainers/HomePage/GetStartedSection/index.js @@ -270,7 +270,7 @@ const GetStartedSection = ({ justifyContent="center" alignItems="center" > - + ); diff --git a/newIDE/app/src/MainFrame/EditorContainers/HomePage/HomePageHeader.js b/newIDE/app/src/MainFrame/EditorContainers/HomePage/HomePageHeader.js index be57e993f91f..4139256a0676 100644 --- a/newIDE/app/src/MainFrame/EditorContainers/HomePage/HomePageHeader.js +++ b/newIDE/app/src/MainFrame/EditorContainers/HomePage/HomePageHeader.js @@ -7,7 +7,7 @@ import FlatButton from '../../../UI/FlatButton'; import { Column, Line } from '../../../UI/Grid'; import { LineStackLayout } from '../../../UI/Layout'; import UserChip from '../../../UI/User/UserChip'; -import ProjectManager from '../../../UI/CustomSvgIcons/ProjectManager'; +import ProjectManagerIcon from '../../../UI/CustomSvgIcons/ProjectManager'; import FloppyIcon from '../../../UI/CustomSvgIcons/Floppy'; import Window from '../../../Utils/Window'; import optionalRequire from '../../../Utils/OptionalRequire'; @@ -57,7 +57,7 @@ export const HomePageHeader = ({ color="default" disabled={!hasProject} > - + {!!hasProject && ( , }, + { + label: Manage, + tab: 'manage', + id: 'home-manage-tab', + getIcon: color => , + }, { label: Shop, tab: 'shop', @@ -185,9 +193,9 @@ export const HomePageMenu = ({ - {tabsToDisplay.map(({ label, tab, getIcon }, index) => ( + {tabsToDisplay.map(({ label, tab, getIcon, id }) => ( { setActiveTab(tab); diff --git a/newIDE/app/src/MainFrame/EditorContainers/HomePage/HomePageMenuBar.js b/newIDE/app/src/MainFrame/EditorContainers/HomePage/HomePageMenuBar.js index be0dde8a0417..90128cf12a5a 100644 --- a/newIDE/app/src/MainFrame/EditorContainers/HomePage/HomePageMenuBar.js +++ b/newIDE/app/src/MainFrame/EditorContainers/HomePage/HomePageMenuBar.js @@ -112,6 +112,7 @@ const HomePageMenuBar = ({ setActiveTab(tab); }} selected={isActive} + id={id} > {getIcon(isActive ? 'inherit' : 'secondary')} @@ -135,6 +136,7 @@ const HomePageMenuBar = ({ disableFocusRipple style={styles.button} onClick={onClick} + id={id} > {getIcon('secondary')} diff --git a/newIDE/app/src/MainFrame/EditorContainers/HomePage/ManageSection/index.js b/newIDE/app/src/MainFrame/EditorContainers/HomePage/ManageSection/index.js new file mode 100644 index 000000000000..2a3241c1d11c --- /dev/null +++ b/newIDE/app/src/MainFrame/EditorContainers/HomePage/ManageSection/index.js @@ -0,0 +1,240 @@ +// @flow +import * as React from 'react'; +import { Trans } from '@lingui/macro'; + +import SectionContainer, { SectionRow } from '../SectionContainer'; +import ErrorBoundary from '../../../../UI/ErrorBoundary'; +import AuthenticatedUserContext from '../../../../Profile/AuthenticatedUserContext'; +import GamesList from '../../../../GameDashboard/GamesList'; +import { type Game } from '../../../../Utils/GDevelopServices/Game'; +import PlaceholderError from '../../../../UI/PlaceholderError'; +import PlaceholderLoader from '../../../../UI/PlaceholderLoader'; +import { Column, LargeSpacer, Line } from '../../../../UI/Grid'; +import Paper from '../../../../UI/Paper'; +import BackgroundText from '../../../../UI/BackgroundText'; +import { + ColumnStackLayout, + ResponsiveLineStackLayout, +} from '../../../../UI/Layout'; +import RaisedButton from '../../../../UI/RaisedButton'; +import FlatButton from '../../../../UI/FlatButton'; +import Link from '../../../../UI/Link'; +import Window from '../../../../Utils/Window'; +import { getHelpLink } from '../../../../Utils/HelpLink'; +import GameDetails, { + gameDetailsTabs, + type GameDetailsTab, +} from '../../../../GameDashboard/GameDetails'; +import { Tabs } from '../../../../UI/Tabs'; +import Text from '../../../../UI/Text'; + +const publishingWikiArticle = getHelpLink('/publishing/'); + +const styles = { + backgroundMessage: { padding: 16 }, + buttonContainer: { minWidth: 150 }, + gameDetailsContainer: { padding: 8, flex: 1, display: 'flex' }, +}; + +type Props = {| + project: ?gdProject, + games: ?Array, + onRefreshGames: () => Promise, + onGameUpdated: Game => void, + gamesFetchingError: ?Error, + openedGame: ?Game, + setOpenedGame: (?Game) => void, + currentTab: GameDetailsTab, + setCurrentTab: GameDetailsTab => void, +|}; + +const ManageSection = ({ + project, + games, + onRefreshGames, + onGameUpdated, + gamesFetchingError, + openedGame, + setOpenedGame, + currentTab, + setCurrentTab, +}: Props) => { + const authenticatedUser = React.useContext(AuthenticatedUserContext); + const { + profile, + onOpenCreateAccountDialog, + onOpenLoginDialog, + } = authenticatedUser; + + const onBack = React.useCallback( + () => { + setCurrentTab('details'); + setOpenedGame(null); + }, + [setCurrentTab, setOpenedGame] + ); + + if (openedGame) { + return ( + + + {openedGame.gameName} + + + + + + + { + onBack(); + onRefreshGames(); + }} + onLoading={() => {}} + currentTab={currentTab} + setCurrentTab={setCurrentTab} + analyticsSource="homepage" + /> + + + + + ); + } + + return ( + Manage Games}> + + {!profile ? ( + + + + + Log-in or create an account to access your{' '} + + Window.openExternalURL(publishingWikiArticle) + } + > + published games + {' '} + retention metrics, and player feedback. + + + +
+ Login} + onClick={onOpenLoginDialog} + /> +
+
+ Create an account} + onClick={onOpenCreateAccountDialog} + /> +
+
+
+
+ ) : games ? ( + games.length === 0 ? ( + + + + + + + Learn how many users are playing your game, control + published versions, and collect feedback from play + testers. + + + + + + + + + Window.openExternalURL(publishingWikiArticle) + } + > + Share a project + {' '} + to get started. + + + + + + + ) : ( + + ) + ) : gamesFetchingError ? ( + + + Can't load the games. Verify your internet connection or retry + later. + + + ) : ( + + + + )} +
+
+ ); +}; + +const ManageSectionWithErrorBoundary = (props: Props) => ( + Manage section} + scope="start-page-manage" + > + + +); + +export default ManageSectionWithErrorBoundary; diff --git a/newIDE/app/src/MainFrame/EditorContainers/HomePage/SectionContainer.js b/newIDE/app/src/MainFrame/EditorContainers/HomePage/SectionContainer.js index 6b1fce3733f3..56890b4cc363 100644 --- a/newIDE/app/src/MainFrame/EditorContainers/HomePage/SectionContainer.js +++ b/newIDE/app/src/MainFrame/EditorContainers/HomePage/SectionContainer.js @@ -90,17 +90,17 @@ const SectionContainer = ({ + {backAction && ( + + } + label={Back} + /> + + )} {title && ( - {backAction && ( - - } - label={Back} - /> - - )} routeArguments['initial-dialog'] === 'asset-store' || // Compatibility with old links routeArguments['initial-dialog'] === 'store'; // New way of opening the store +const isGamesDashboardRequested = (routeArguments: RouteArguments): boolean => + routeArguments['initial-dialog'] === 'games-dashboard'; const styles = { container: { @@ -166,6 +179,11 @@ export const HomePage = React.memo( shop: { setInitialGameTemplateUserFriendlySlug }, } = React.useContext(PrivateGameTemplateStoreContext); const [showUserChip, setShowUserChip] = React.useState(false); + const [openedGame, setOpenedGame] = React.useState(null); + const [ + gameDetailsCurrentTab, + setGameDetailsCurrentTab, + ] = React.useState('details'); const windowWidth = useResponsiveWindowWidth(); const isMobile = windowWidth === 'small'; @@ -187,6 +205,38 @@ export const HomePage = React.memo( const isShopRequestedAtOpening = React.useRef( isShopRequested(routeArguments) ); + const isGamesDashboardRequestedAtOpening = React.useRef( + isGamesDashboardRequested(routeArguments) + ); + const [ + displayTooltipDelayed, + setDisplayTooltipDelayed, + ] = React.useState(false); + const { + games, + gamesFetchingError, + onGameUpdated, + fetchGames, + } = useGamesList(); + const { + shouldDisplayNewFeatureHighlighting, + acknowledgeNewFeature, + } = useDisplayNewFeature(); + const manageTabElement = document.getElementById('home-manage-tab'); + const shouldDisplayTooltip = shouldDisplayNewFeatureHighlighting({ + featureId: 'gamesDashboardInHomePage', + }); + + const displayTooltip = + isActive && shouldDisplayTooltip && manageTabElement; + + const onCloseTooltip = React.useCallback( + () => { + setDisplayTooltipDelayed(false); + acknowledgeNewFeature({ featureId: 'gamesDashboardInHomePage' }); + }, + [acknowledgeNewFeature] + ); // Open the store and a pack or game template if asked to do so. React.useEffect( @@ -207,6 +257,9 @@ export const HomePage = React.memo( 'asset-pack', 'game-template', ]); + } else if (isGamesDashboardRequested(routeArguments)) { + setActiveTab('manage'); + removeRouteArguments(['initial-dialog']); } }, [ @@ -217,10 +270,15 @@ export const HomePage = React.memo( ] ); - // If the user is not authenticated, the GetStarted section is displayed. + // If the user is not authenticated, the GetStarted section is displayed unless + // a specific tab is requested via the url. React.useEffect( () => { - if (isShopRequestedAtOpening.current) return; + if ( + isShopRequestedAtOpening.current || + isGamesDashboardRequestedAtOpening.current + ) + return; if (shouldChangeTabAfterUserLoggedIn.current) { setActiveTab(authenticated ? initialTab : 'get-started'); } @@ -265,6 +323,35 @@ export const HomePage = React.memo( [fetchExamplesAndFilters, fetchTutorials, fetchGameTemplates] ); + // Only fetch games if the user decides to open the games dashboard tab + // or the build tab to enable the context menu on project list items that + // redirects to the games dashboard. + React.useEffect( + () => { + if ((activeTab === 'manage' || activeTab === 'build') && !games) { + fetchGames(); + } + }, + [fetchGames, activeTab, games] + ); + + React.useEffect( + () => { + if (displayTooltip) { + const timeoutId = setTimeout(() => { + setDisplayTooltipDelayed(true); + }, 500); + return () => clearTimeout(timeoutId); + } else { + setDisplayTooltipDelayed(false); + } + }, + // Delay display of tooltip because home tab is the first to be opened + // but the editor might open a project at start, displaying the tooltip + // while the project is loading, giving the impression of a glitch. + [displayTooltip] + ); + // Fetch user cloud projects when home page becomes active React.useEffect( () => { @@ -367,6 +454,34 @@ export const HomePage = React.memo( [authenticated] ); + const handleGameUpdated = React.useCallback( + (game: Game) => { + onGameUpdated(game); + if (openedGame) setOpenedGame(game); + }, + [onGameUpdated, openedGame] + ); + + const onManageGame = React.useCallback( + ({ gameId }: { gameId: string }) => { + if (!games) return; + const matchingGame = games.find(game => game.id === gameId); + if (!matchingGame) return; + setOpenedGame(matchingGame); + setActiveTab('manage'); + }, + [games] + ); + + const canManageGame = React.useCallback( + ({ gameId }: { gameId: string }): boolean => { + if (!games) return false; + const matchingGameIndex = games.findIndex(game => game.id === gameId); + return matchingGameIndex > -1; + }, + [games] + ); + const shouldDisplayAnnouncements = activeTab !== 'community' && // Get started page displays announcements itself. @@ -382,6 +497,19 @@ export const HomePage = React.memo( {shouldDisplayAnnouncements && ( )} + {activeTab === 'manage' && ( + + )} {activeTab === 'get-started' && ( ( onOpenExampleStoreWithPrivateGameTemplateListingData } onOpenRecentFile={onOpenRecentFile} + onManageGame={onManageGame} + canManageGame={canManageGame} storageProviders={storageProviders} i18n={i18n} /> @@ -445,6 +575,36 @@ export const HomePage = React.memo( onOpenAbout={onOpenAbout} /> + {displayTooltipDelayed && ( + Games Dashboard} + thumbnailSource="res/features/games-dashboard.svg" + thumbnailAlt={'Red hero presenting games analytics'} + content={[ + + + Follow your games’ online performance, manage published + versions, and collect player feedback. + + , + + + Window.openExternalURL(gamesDashboardWikiArticle) + } + > + Learn more + + , + ]} + placement={isMobile ? 'bottom' : 'right'} + onClose={onCloseTooltip} + closeWithBackdropClick={false} + /> + )} )} diff --git a/newIDE/app/src/MainFrame/Preferences/PreferencesContext.js b/newIDE/app/src/MainFrame/Preferences/PreferencesContext.js index f122f571e582..aa439b9483d5 100644 --- a/newIDE/app/src/MainFrame/Preferences/PreferencesContext.js +++ b/newIDE/app/src/MainFrame/Preferences/PreferencesContext.js @@ -215,6 +215,9 @@ export type PreferencesValues = {| newProjectsDefaultStorageProviderName: string, useShortcutToClosePreviewWindow: boolean, watchProjectFolderFilesForLocalProjects: boolean, + newFeaturesAcknowledgements: { + [featureId: string]: {| dates: [number] |}, + }, editorStateByProject: { [string]: { editorTabs: EditorTabsPersistedState } }, |}; @@ -297,6 +300,9 @@ export type Preferences = {| setNewProjectsDefaultFolder: (newProjectsDefaultFolder: string) => void, setUseShortcutToClosePreviewWindow: (enabled: boolean) => void, setWatchProjectFolderFilesForLocalProjects: (enabled: boolean) => void, + setNewFeaturesAcknowledgements: ({ + [featureId: string]: {| dates: [number] |}, + }) => void, getEditorStateForProject: ( projectId: string ) => ?{| editorTabs: EditorTabsPersistedState |}, @@ -350,6 +356,7 @@ export const initialPreferences = { newProjectsDefaultStorageProviderName: 'Cloud', useShortcutToClosePreviewWindow: true, watchProjectFolderFilesForLocalProjects: true, + newFeaturesAcknowledgements: {}, editorStateByProject: {}, }, setLanguage: () => {}, @@ -411,6 +418,7 @@ export const initialPreferences = { setNewProjectsDefaultStorageProviderName: () => {}, setUseShortcutToClosePreviewWindow: () => {}, setWatchProjectFolderFilesForLocalProjects: () => {}, + setNewFeaturesAcknowledgements: () => {}, getEditorStateForProject: projectId => {}, setEditorStateForProject: (projectId, editorState) => {}, }; diff --git a/newIDE/app/src/MainFrame/Preferences/PreferencesProvider.js b/newIDE/app/src/MainFrame/Preferences/PreferencesProvider.js index f418906ee191..053e52eb6230 100644 --- a/newIDE/app/src/MainFrame/Preferences/PreferencesProvider.js +++ b/newIDE/app/src/MainFrame/Preferences/PreferencesProvider.js @@ -170,6 +170,9 @@ export default class PreferencesProvider extends React.Component { setWatchProjectFolderFilesForLocalProjects: this._setWatchProjectFolderFilesForLocalProjects.bind( this ), + setNewFeaturesAcknowledgements: this._setNewFeaturesAcknowledgements.bind( + this + ), getEditorStateForProject: this._getEditorStateForProject.bind(this), setEditorStateForProject: this._setEditorStateForProject.bind(this), }; @@ -861,6 +864,20 @@ export default class PreferencesProvider extends React.Component { ); } + _setNewFeaturesAcknowledgements(newFeaturesAcknowledgements: { + [featureId: string]: {| dates: [number] |}, + }) { + this.setState( + state => ({ + values: { + ...state.values, + newFeaturesAcknowledgements, + }, + }), + () => this._persistValuesToLocalStorage(this.state) + ); + } + _getEditorStateForProject(projectId: string) { return this.state.values.editorStateByProject[projectId]; } diff --git a/newIDE/app/src/MainFrame/index.js b/newIDE/app/src/MainFrame/index.js index 41d662e0be23..fe39da5fdc14 100644 --- a/newIDE/app/src/MainFrame/index.js +++ b/newIDE/app/src/MainFrame/index.js @@ -302,7 +302,7 @@ export type Props = {| resourceSources: Array, resourceExternalEditors: Array, requestUpdate?: () => void, - renderShareDialog?: ShareDialogWithoutExportsProps => React.Node, + renderShareDialog: ShareDialogWithoutExportsProps => React.Node, renderGDJSDevelopmentWatcher?: ?() => React.Node, extensionsLoader?: JsExtensionsLoader, initialFileMetadataToOpen: ?FileMetadata, @@ -3043,11 +3043,11 @@ const MainFrame = (props: Props) => { currentProject ); }} + onShareProject={() => setShareDialogOpen(true)} freezeUpdate={!projectManagerOpen} unsavedChanges={unsavedChanges} hotReloadPreviewButtonProps={hotReloadPreviewButtonProps} resourceManagementProps={resourceManagementProps} - shortcutMap={shortcutMap} /> )} {!state.currentProject && ( @@ -3242,8 +3242,7 @@ const MainFrame = (props: Props) => { }} message={{state.snackMessage}} /> - {!!renderShareDialog && - shareDialogOpen && + {shareDialogOpen && renderShareDialog({ onClose: closeShareDialog, onChangeSubscription: closeShareDialog, diff --git a/newIDE/app/src/Profile/ProfileDialog.js b/newIDE/app/src/Profile/ProfileDialog.js index 4625e593a935..7bbdeef1ca47 100644 --- a/newIDE/app/src/Profile/ProfileDialog.js +++ b/newIDE/app/src/Profile/ProfileDialog.js @@ -12,16 +12,21 @@ import SubscriptionDetails from './Subscription/SubscriptionDetails'; import ContributionsDetails from './ContributionsDetails'; import UserAchievements from './Achievement/UserAchievements'; import AuthenticatedUserContext from './AuthenticatedUserContext'; -import { GamesList } from '../GameDashboard/GamesList'; +import GamesList from '../GameDashboard/GamesList'; import { getRedirectToSubscriptionPortalUrl } from '../Utils/GDevelopServices/Usage'; import Window from '../Utils/Window'; import { showErrorBox } from '../UI/Messages/MessageBox'; import CreateProfile from './CreateProfile'; import PlaceholderLoader from '../UI/PlaceholderLoader'; -import RouterContext from '../MainFrame/RouterContext'; import useIsElementVisibleInScroll from '../Utils/UseIsElementVisibleInScroll'; import { markBadgesAsSeen as doMarkBadgesAsSeen } from '../Utils/GDevelopServices/Badge'; import ErrorBoundary from '../UI/ErrorBoundary'; +import PlaceholderError from '../UI/PlaceholderError'; +import useGamesList from '../GameDashboard/UseGamesList'; +import { type Game } from '../Utils/GDevelopServices/Game'; +import { GameDetailsDialog } from '../GameDashboard/GameDetailsDialog'; +import { ColumnStackLayout } from '../UI/Layout'; +import AlertMessage from '../UI/AlertMessage'; export type ProfileTab = 'profile' | 'games-dashboard'; @@ -32,26 +37,22 @@ type Props = {| |}; const ProfileDialog = ({ currentProject, open, onClose }: Props) => { - const { routeArguments, removeRouteArguments } = React.useContext( - RouterContext - ); const badgesSeenNotificationTimeoutRef = React.useRef(null); const badgesSeenNotificationSentRef = React.useRef(false); + const [openedGame, setOpenedGame] = React.useState(null); const [currentTab, setCurrentTab] = React.useState('profile'); const authenticatedUser = React.useContext(AuthenticatedUserContext); const isUserLoading = authenticatedUser.loginState !== 'done'; const userAchievementsContainerRef = React.useRef(null); + const { + games, + gamesFetchingError, + onGameUpdated, + fetchGames, + } = useGamesList(); - React.useEffect( - () => { - if (routeArguments['initial-dialog'] === 'games-dashboard') { - setCurrentTab('games-dashboard'); - removeRouteArguments(['initial-dialog']); - } - }, - [routeArguments, removeRouteArguments] - ); + const projectUuid = currentProject ? currentProject.getProjectUuid() : null; const markBadgesAsSeen = React.useCallback( (entries: IntersectionObserverEntry[]) => { @@ -133,6 +134,16 @@ const ProfileDialog = ({ currentProject, open, onClose }: Props) => { [authenticatedUser.onRefreshUserProfile, open] // eslint-disable-line react-hooks/exhaustive-deps ); + // Only fetch games if the user decides to open the games dashboard tab. + React.useEffect( + () => { + if (currentTab === 'games-dashboard' && !games) { + fetchGames(); + } + }, + [fetchGames, currentTab, games] + ); + const onLogout = React.useCallback( async () => { await authenticatedUser.onLogout(); @@ -231,9 +242,33 @@ const ProfileDialog = ({ currentProject, open, onClose }: Props) => { )} - {currentTab === 'games-dashboard' && ( - - )} + {currentTab === 'games-dashboard' && + (games ? ( + + + + You can find the Games Dashboard on the home page under the + Manage tab. + + + + + ) : gamesFetchingError ? ( + + + Can't load the games. Verify your internet connection or retry + later. + + + ) : ( + + ))} ) : ( @@ -252,6 +287,28 @@ const ProfileDialog = ({ currentProject, open, onClose }: Props) => { /> )} + {openedGame && ( + { + setOpenedGame(null); + }} + onGameUpdated={updatedGame => { + onGameUpdated(updatedGame); + setOpenedGame(updatedGame); + }} + onGameDeleted={() => { + setOpenedGame(null); + fetchGames(); + }} + analyticsSource="profile" + /> + )} ); }; diff --git a/newIDE/app/src/ProjectManager/GamesDashboardInfo.js b/newIDE/app/src/ProjectManager/GamesDashboardInfo.js new file mode 100644 index 000000000000..c0d237cb33b5 --- /dev/null +++ b/newIDE/app/src/ProjectManager/GamesDashboardInfo.js @@ -0,0 +1,197 @@ +// @flow + +import * as React from 'react'; +import { Trans } from '@lingui/macro'; + +import Link from '../UI/Link'; +import RaisedButton from '../UI/RaisedButton'; +import Text from '../UI/Text'; +import { ColumnStackLayout, LineStackLayout } from '../UI/Layout'; +import Graphs from '../UI/CustomSvgIcons/Graphs'; +import GDevelopThemeContext from '../UI/Theme/GDevelopThemeContext'; +import { getHelpLink } from '../Utils/HelpLink'; +import Window from '../Utils/Window'; +import useDisplayNewFeature from '../Utils/UseDisplayNewFeature'; +import HighlightingTooltip from '../UI/HighlightingTooltip'; +import Publish from '../UI/CustomSvgIcons/Publish'; +import Paper from '../UI/Paper'; +import AuthenticatedUserContext from '../Profile/AuthenticatedUserContext'; +import { ListItem } from '../UI/List'; +import { getProjectManagerItemId } from '.'; +import { useResponsiveWindowWidth } from '../UI/Reponsive/ResponsiveWindowMeasurer'; + +const publishingWikiArticle = getHelpLink('/publishing/'); +const gamesDashboardWikiArticle = getHelpLink('/interface/games-dashboard/'); + +const styles = { + gamesDashboardInfoContainer: { + border: '1px solid', + padding: 8, + margin: 4, + }, + gamesDashboardInfoTextContainer: { + opacity: 0.7, + }, +}; + +type Props = {| + onShareProject: () => void, + onOpenGamesDashboardDialog?: ?() => void, + canDisplayTooltip: boolean, +|}; + +const GamesDashboardInfo = ({ + onShareProject, + onOpenGamesDashboardDialog, + canDisplayTooltip, +}: Props) => { + const { profile, onOpenLoginDialog } = React.useContext( + AuthenticatedUserContext + ); + const gdevelopTheme = React.useContext(GDevelopThemeContext); + const { + shouldDisplayNewFeatureHighlighting, + acknowledgeNewFeature, + } = useDisplayNewFeature(); + const windowWidth = useResponsiveWindowWidth(); + const isMobile = windowWidth === 'small'; + + const [ + gameDashboardItemContainer, + setGameDashboardItemContainer, + ] = React.useState(null); + const [ + displayTooltipDelayed, + setDisplayTooltipDelayed, + ] = React.useState(false); + + const onClickShare = React.useCallback( + () => { + if (!!profile) { + onShareProject(); + } else { + onOpenLoginDialog(); + } + }, + [profile, onShareProject, onOpenLoginDialog] + ); + + const onCloseTooltip = React.useCallback( + () => { + setDisplayTooltipDelayed(false); + acknowledgeNewFeature({ featureId: 'gamesDashboardInProjectManager' }); + }, + [acknowledgeNewFeature] + ); + + const shouldDisplayTooltip = shouldDisplayNewFeatureHighlighting({ + featureId: 'gamesDashboardInProjectManager', + }); + + const displayTooltip = + canDisplayTooltip && shouldDisplayTooltip && gameDashboardItemContainer; + + React.useEffect( + () => { + if (displayTooltip) { + const timeoutId = setTimeout(() => { + setDisplayTooltipDelayed(true); + }, 500); + return () => clearTimeout(timeoutId); + } + }, + // Delay display of tooltip because the project manager opening is animated + // and the popper does not follow the item. + [displayTooltip] + ); + + if (onOpenGamesDashboardDialog) { + return ( +
setGameDashboardItemContainer(ref)}> + Game Dashboard} + leftIcon={} + onClick={onOpenGamesDashboardDialog} + noPadding + /> + {displayTooltipDelayed && ( + Game Dashboard} + content={[ + + + Follow your game’s online performance, manage published + versions, and collect player feedback. + + , + + + Window.openExternalURL(gamesDashboardWikiArticle) + } + > + Learn more + + , + ]} + placement={isMobile ? 'bottom' : 'right'} + onClose={onCloseTooltip} + closeWithBackdropClick + /> + )} +
+ ); + } + + return ( + + +
+ + + + + Games Dashboard + + + + + Share your project online to unlock player engagement analytics, + player feedback and other functionalities. + + + +
+ + Window.openExternalURL(publishingWikiArticle)} + > + Learn more + + + Share} + icon={} + onClick={onClickShare} + /> +
+
+ ); +}; + +export default GamesDashboardInfo; diff --git a/newIDE/app/src/ProjectManager/ShortcutsReminder.js b/newIDE/app/src/ProjectManager/ShortcutsReminder.js deleted file mode 100644 index 574c57cd360b..000000000000 --- a/newIDE/app/src/ProjectManager/ShortcutsReminder.js +++ /dev/null @@ -1,74 +0,0 @@ -// @flow -import * as React from 'react'; -import { Trans } from '@lingui/macro'; -import { useResponsiveWindowWidth } from '../UI/Reponsive/ResponsiveWindowMeasurer'; -import optionalRequire from '../Utils/OptionalRequire'; -import AlertMessage from '../UI/AlertMessage'; -import { Line, Spacer } from '../UI/Grid'; -import Text from '../UI/Text'; -import { adaptAcceleratorString } from '../UI/AcceleratorString'; -import { getElectronAccelerator } from '../KeyboardShortcuts'; -import { type ShortcutMap } from '../KeyboardShortcuts/DefaultShortcuts'; -import { Column } from '../UI/Grid'; -const electron = optionalRequire('electron'); - -const styles = { - shortcutReminders: { - opacity: 0.7, - }, -}; - -const shortcuts = [ - { - label: Save, - shortcutMapKey: 'SAVE_PROJECT', - }, - { - label: Save as..., - shortcutMapKey: 'SAVE_PROJECT_AS', - }, - { - label: Export, - shortcutMapKey: 'EXPORT_GAME', - }, - { - label: Close, - shortcutMapKey: 'CLOSE_PROJECT', - }, -]; - -export const ShortcutsReminder = ({ - shortcutMap, -}: {| - shortcutMap: ShortcutMap, -|}) => { - const windowWidth = useResponsiveWindowWidth(); - const isMobileScreen = windowWidth === 'small'; - - if (isMobileScreen) return null; - if (!!electron) return null; - - return ( - - - Find these actions on the Menu close to the “Home” tab. - - -
- {shortcuts.map(({ label, shortcutMapKey }, index) => ( - - - {label} - - - {adaptAcceleratorString( - getElectronAccelerator(shortcutMap[shortcutMapKey]) - )} - - - ))} -
- -
- ); -}; diff --git a/newIDE/app/src/ProjectManager/index.js b/newIDE/app/src/ProjectManager/index.js index 29ba4b7ea288..15a4293c3e73 100644 --- a/newIDE/app/src/ProjectManager/index.js +++ b/newIDE/app/src/ProjectManager/index.js @@ -25,10 +25,6 @@ import { } from '../Utils/Serializer'; import ExtensionsSearchDialog from '../AssetStore/ExtensionStore/ExtensionsSearchDialog'; import Flag from '@material-ui/icons/Flag'; -import SettingsApplications from '@material-ui/icons/SettingsApplications'; -import PhotoLibrary from '@material-ui/icons/PhotoLibrary'; -import VariableTree from '../UI/CustomSvgIcons/VariableTree'; -import ArtTrack from '@material-ui/icons/ArtTrack'; import ScenePropertiesDialog from '../SceneEditor/ScenePropertiesDialog'; import SceneVariablesDialog from '../SceneEditor/SceneVariablesDialog'; import { isExtensionNameTaken } from './EventFunctionExtensionNameVerifier'; @@ -48,13 +44,20 @@ import Tooltip from '@material-ui/core/Tooltip'; import SceneIcon from '../UI/CustomSvgIcons/Scene'; import ExternalLayoutIcon from '../UI/CustomSvgIcons/ExternalLayout'; import ExternalEventsIcon from '../UI/CustomSvgIcons/ExternalEvents'; -import { type ShortcutMap } from '../KeyboardShortcuts/DefaultShortcuts'; -import { ShortcutsReminder } from './ShortcutsReminder'; import Paper from '../UI/Paper'; import { makeDragSourceAndDropTarget } from '../UI/DragAndDrop/DragSourceAndDropTarget'; -import { useScreenType } from '../UI/Reponsive/ScreenTypeMeasurer'; +import { useShouldAutofocusInput } from '../UI/Reponsive/ScreenTypeMeasurer'; import { addDefaultLightToAllLayers } from '../ProjectCreation/CreateProject'; import ErrorBoundary from '../UI/ErrorBoundary'; +import Settings from '../UI/CustomSvgIcons/Settings'; +import Picture from '../UI/CustomSvgIcons/Picture'; +import Publish from '../UI/CustomSvgIcons/Publish'; +import ProjectResources from '../UI/CustomSvgIcons/ProjectResources'; +import GamesDashboardInfo from './GamesDashboardInfo'; +import useForceUpdate from '../Utils/UseForceUpdate'; +import useGamesList from '../GameDashboard/UseGamesList'; +import AuthenticatedUserContext from '../Profile/AuthenticatedUserContext'; +import { GameDetailsDialog } from '../GameDashboard/GameDetailsDialog'; const LAYOUT_CLIPBOARD_KIND = 'Layout'; const EXTERNAL_LAYOUT_CLIPBOARD_KIND = 'External layout'; @@ -82,7 +85,7 @@ const styles = { overflowY: 'scroll', scrollbarWidth: 'thin', // For Firefox, to avoid having a very large scrollbar. marginTop: 16, - padding: '0 16px 16px 16px', + padding: '0 8px 12px 12px', position: 'relative', }, searchBarContainer: { @@ -99,7 +102,8 @@ type ProjectItemKind = | 'external-layout' | 'events-functions-extension'; -const getTabId = (identifier: string) => `project-manager-tab-${identifier}`; +export const getProjectManagerItemId = (identifier: string) => + `project-manager-tab-${identifier}`; type Props = {| project: gdProject, @@ -125,608 +129,692 @@ type Props = {| unsavedChanges?: UnsavedChanges, hotReloadPreviewButtonProps: HotReloadPreviewButtonProps, onInstallExtension: ExtensionShortHeader => void, - shortcutMap: ShortcutMap, + onShareProject: () => void, // For resources: resourceManagementProps: ResourceManagementProps, |}; -type State = {| - editedPropertiesLayout: ?gdLayout, - editedVariablesLayout: ?gdLayout, - renamedItemKind: ?ProjectItemKind, - renamedItemName: string, - searchText: string, - projectPropertiesDialogOpen: boolean, - projectPropertiesDialogInitialTab: 'properties' | 'loading-screen', - projectVariablesEditorOpen: boolean, - extensionsSearchDialogOpen: boolean, - openedExtensionShortHeader: ?ExtensionShortHeader, - openedExtensionName: ?string, - isInstallingExtension: boolean, - layoutPropertiesDialogOpen: boolean, - layoutVariablesDialogOpen: boolean, -|}; - -class ProjectManager extends React.Component { - _searchBar: ?SearchBarInterface; - _draggedLayoutIndex: number | null = null; - _draggedExternalLayoutIndex: number | null = null; - _draggedExternalEventsIndex: number | null = null; - _draggedExtensionIndex: number | null = null; - - state = { - editedPropertiesLayout: null, - editedVariablesLayout: null, - renamedItemKind: null, - renamedItemName: '', - searchText: '', - projectPropertiesDialogOpen: false, - projectPropertiesDialogInitialTab: 'properties', - projectVariablesEditorOpen: false, - extensionsSearchDialogOpen: false, - openedExtensionShortHeader: null, - openedExtensionName: null, - isInstallingExtension: false, - layoutPropertiesDialogOpen: false, - layoutVariablesDialogOpen: false, - }; - - shouldComponentUpdate(nextProps: Props, nextState: State) { - if ( - nextState.projectPropertiesDialogOpen !== - this.state.projectPropertiesDialogOpen || - nextState.projectVariablesEditorOpen !== - this.state.projectVariablesEditorOpen || - nextState.extensionsSearchDialogOpen !== - this.state.extensionsSearchDialogOpen || - nextState.openedExtensionShortHeader !== - this.state.openedExtensionShortHeader - ) - return true; - - // Rendering the component is (super) costly (~20ms) as it iterates over - // every project layouts/external layouts/external events, - // so the prop freezeUpdate allow to ask the component to stop - // updating, for example when hidden. - return !nextProps.freezeUpdate; - } - - componentDidUpdate(prevProps: Props) { - // Typical usage (don't forget to compare props): - if (!this.props.freezeUpdate && prevProps.freezeUpdate) { - // TODO: When this component is refactored into a functional component, - // use useShouldAutofocusInput. - // eslint-disable-next-line react-hooks/rules-of-hooks - if (useScreenType() === 'normal' && this._searchBar) - this._searchBar.focus(); - } - } - - _openProjectProperties = () => { - this.setState({ - projectPropertiesDialogOpen: true, - projectPropertiesDialogInitialTab: 'properties', - }); - }; - - _openProjectLoadingScreen = () => { - this.setState({ - projectPropertiesDialogOpen: true, - projectPropertiesDialogInitialTab: 'loading-screen', - }); - }; - - _openProjectVariables = () => { - this.setState({ - projectVariablesEditorOpen: true, - }); - }; - - _openSearchExtensionDialog = () => { - this.setState({ extensionsSearchDialogOpen: true }); - }; +const ProjectManager = React.memo( + ({ + project, + onChangeProjectName, + onSaveProjectProperties, + onDeleteLayout, + onDeleteExternalEvents, + onDeleteExternalLayout, + onDeleteEventsFunctionsExtension, + onRenameLayout, + onRenameExternalEvents, + onRenameExternalLayout, + onRenameEventsFunctionsExtension, + onOpenLayout, + onOpenExternalEvents, + onOpenExternalLayout, + onOpenEventsFunctionsExtension, + onOpenResources, + onOpenPlatformSpecificAssets, + eventsFunctionsExtensionsError, + onReloadEventsFunctionsExtensions, + freezeUpdate, + unsavedChanges, + hotReloadPreviewButtonProps, + onInstallExtension, + onShareProject, + resourceManagementProps, + }: Props) => { + const forceUpdate = useForceUpdate(); + const shouldAutofocusInput = useShouldAutofocusInput(); + const { profile } = React.useContext(AuthenticatedUserContext); + const userId = profile ? profile.id : null; + const { games, fetchGames } = useGamesList(); + + const searchBarRef = React.useRef(null); + const draggedLayoutIndexRef = React.useRef(null); + const draggedExternalLayoutIndexRef = React.useRef(null); + const draggedExternalEventsIndexRef = React.useRef(null); + const draggedExtensionIndexRef = React.useRef(null); + + const [editedPropertiesLayout, setEditedPropertiesLayout] = React.useState( + null + ); + const [editedVariablesLayout, setEditedVariablesLayout] = React.useState( + null + ); + const [renamedItemKind, setRenamedItemKind] = React.useState(null); + const [renamedItemName, setRenamedItemName] = React.useState(''); + const [searchText, setSearchText] = React.useState(''); + const [ + projectPropertiesDialogOpen, + setProjectPropertiesDialogOpen, + ] = React.useState(false); + const [ + projectPropertiesDialogInitialTab, + setProjectPropertiesDialogInitialTab, + ] = React.useState('properties'); + const [ + projectVariablesEditorOpen, + setProjectVariablesEditorOpen, + ] = React.useState(false); + const [ + extensionsSearchDialogOpen, + setExtensionsSearchDialogOpen, + ] = React.useState(false); + const [ + openedExtensionShortHeader, + setOpenedExtensionShortHeader, + ] = React.useState(null); + const [openedExtensionName, setOpenedExtensionName] = React.useState(null); + const [openGameDetails, setOpenGameDetails] = React.useState( + false + ); - _onEditName = (kind: ?ProjectItemKind, name: string) => { - this.setState({ - renamedItemKind: kind, - renamedItemName: name, - }); - }; + const projectUuid = project.getProjectUuid(); + const gameMatchingProjectUuid = games + ? games.find(game => game.id === projectUuid) + : null; - _copyLayout = (layout: gdLayout) => { - Clipboard.set(LAYOUT_CLIPBOARD_KIND, { - layout: serializeToJSObject(layout), - name: layout.getName(), + React.useEffect(() => { + if (!freezeUpdate && shouldAutofocusInput && searchBarRef.current) { + searchBarRef.current.focus(); + } }); - }; - _cutLayout = (layout: gdLayout) => { - this._copyLayout(layout); - this.props.onDeleteLayout(layout); - }; + React.useEffect( + () => { + fetchGames(); + }, + [fetchGames, userId] + ); - _pasteLayout = (index: number) => { - if (!Clipboard.has(LAYOUT_CLIPBOARD_KIND)) return; + const onProjectItemModified = React.useCallback( + () => { + forceUpdate(); + if (unsavedChanges) unsavedChanges.triggerUnsavedChanges(); + }, + [forceUpdate, unsavedChanges] + ); - const clipboardContent = Clipboard.get(LAYOUT_CLIPBOARD_KIND); - const copiedLayout = SafeExtractor.extractObjectProperty( - clipboardContent, - 'layout' + const openProjectProperties = React.useCallback(() => { + setProjectPropertiesDialogOpen(true); + setProjectPropertiesDialogInitialTab('properties'); + }, []); + + const openProjectLoadingScreen = React.useCallback(() => { + setProjectPropertiesDialogOpen(true); + setProjectPropertiesDialogInitialTab('loading-screen'); + }, []); + + const openProjectVariables = React.useCallback(() => { + setProjectVariablesEditorOpen(true); + }, []); + + const openSearchExtensionDialog = React.useCallback(() => { + setExtensionsSearchDialogOpen(true); + }, []); + + const onEditName = React.useCallback( + (kind: ?ProjectItemKind, name: string) => { + setRenamedItemKind(kind); + setRenamedItemName(name); + }, + [] ); - const name = SafeExtractor.extractStringProperty(clipboardContent, 'name'); - if (!name || !copiedLayout) return; - const { project } = this.props; + const copyLayout = React.useCallback((layout: gdLayout) => { + Clipboard.set(LAYOUT_CLIPBOARD_KIND, { + layout: serializeToJSObject(layout), + name: layout.getName(), + }); + }, []); + + const cutLayout = React.useCallback( + (layout: gdLayout) => { + copyLayout(layout); + onDeleteLayout(layout); + }, + [onDeleteLayout, copyLayout] + ); - const newName = newNameGenerator(name, name => - project.hasLayoutNamed(name) + const pasteLayout = React.useCallback( + (index: number) => { + if (!Clipboard.has(LAYOUT_CLIPBOARD_KIND)) return; + + const clipboardContent = Clipboard.get(LAYOUT_CLIPBOARD_KIND); + const copiedLayout = SafeExtractor.extractObjectProperty( + clipboardContent, + 'layout' + ); + const name = SafeExtractor.extractStringProperty( + clipboardContent, + 'name' + ); + if (!name || !copiedLayout) return; + + const newName = newNameGenerator(name, name => + project.hasLayoutNamed(name) + ); + + const newLayout = project.insertNewLayout(newName, index); + + unserializeFromJSObject( + newLayout, + copiedLayout, + 'unserializeFrom', + project + ); + newLayout.setName(newName); // Unserialization has overwritten the name. + newLayout.updateBehaviorsSharedData(project); + + onProjectItemModified(); + }, + [project, onProjectItemModified] ); - const newLayout = project.insertNewLayout(newName, index); + const duplicateLayout = React.useCallback( + (layout: gdLayout, index: number) => { + const newName = newNameGenerator(layout.getName(), name => + project.hasLayoutNamed(name) + ); + + const newLayout = project.insertNewLayout(newName, index); + + unserializeFromJSObject( + newLayout, + serializeToJSObject(layout), + 'unserializeFrom', + project + ); + newLayout.setName(newName); // Unserialization has overwritten the name. + newLayout.updateBehaviorsSharedData(project); + + onProjectItemModified(); + }, + [project, onProjectItemModified] + ); - unserializeFromJSObject( - newLayout, - copiedLayout, - 'unserializeFrom', - project + const addLayout = React.useCallback( + (index: number, i18n: I18nType) => { + const newName = newNameGenerator(i18n._(t`Untitled scene`), name => + project.hasLayoutNamed(name) + ); + const newLayout = project.insertNewLayout(newName, index + 1); + newLayout.setName(newName); + newLayout.updateBehaviorsSharedData(project); + addDefaultLightToAllLayers(newLayout); + + onProjectItemModified(); + + // Trigger an edit of the name, so that the user can rename the layout easily. + onEditName('layout', newName); + }, + [project, onProjectItemModified, onEditName] ); - newLayout.setName(newName); // Unserialization has overwritten the name. - newLayout.updateBehaviorsSharedData(project); - this._onProjectItemModified(); - }; + const onOpenLayoutProperties = React.useCallback((layout: ?gdLayout) => { + setEditedPropertiesLayout(layout); + }, []); + + const onOpenLayoutVariables = React.useCallback((layout: ?gdLayout) => { + setEditedVariablesLayout(layout); + }, []); + + const addExternalEvents = React.useCallback( + (index: number, i18n: I18nType) => { + const newName = newNameGenerator( + i18n._(t`Untitled external events`), + name => project.hasExternalEventsNamed(name) + ); + project.insertNewExternalEvents(newName, index + 1); + onProjectItemModified(); + + // Trigger an edit of the name, so that the user can rename the external events easily. + onEditName('external-events', newName); + }, + [project, onProjectItemModified, onEditName] + ); - _duplicateLayout = (layout: gdLayout, index: number) => { - const { project } = this.props; + const addExternalLayout = React.useCallback( + (index: number, i18n: I18nType) => { + const newName = newNameGenerator( + i18n._(t`Untitled external layout`), + name => project.hasExternalLayoutNamed(name) + ); + project.insertNewExternalLayout(newName, index + 1); + onProjectItemModified(); + + // Trigger an edit of the name, so that the user can rename the external layout easily. + onEditName('external-layout', newName); + }, + [project, onEditName, onProjectItemModified] + ); - const newName = newNameGenerator(layout.getName(), name => - project.hasLayoutNamed(name) + const addEventsFunctionsExtension = React.useCallback( + (index: number, i18n: I18nType) => { + const newName = newNameGenerator(i18n._(t`UntitledExtension`), name => + isExtensionNameTaken(name, project) + ); + project.insertNewEventsFunctionsExtension(newName, index + 1); + onProjectItemModified(); + return newName; + }, + [project, onProjectItemModified] ); - const newLayout = project.insertNewLayout(newName, index); + const moveUpLayout = React.useCallback( + (index: number) => { + if (index <= 0) return; - unserializeFromJSObject( - newLayout, - serializeToJSObject(layout), - 'unserializeFrom', - project + project.swapLayouts(index, index - 1); + onProjectItemModified(); + }, + [project, onProjectItemModified] ); - newLayout.setName(newName); // Unserialization has overwritten the name. - newLayout.updateBehaviorsSharedData(project); - this._onProjectItemModified(); - }; + const moveDownLayout = React.useCallback( + (index: number) => { + if (index >= project.getLayoutsCount() - 1) return; - _addLayout = (index: number, i18n: I18nType) => { - const { project } = this.props; + project.swapLayouts(index, index + 1); + onProjectItemModified(); + }, + [project, onProjectItemModified] + ); - const newName = newNameGenerator(i18n._(t`Untitled scene`), name => - project.hasLayoutNamed(name) + const dropOnLayout = React.useCallback( + (targetLayoutIndex: number) => { + const { current: draggedLayoutIndex } = draggedLayoutIndexRef; + if (draggedLayoutIndex === null) return; + + if (targetLayoutIndex !== draggedLayoutIndex) { + project.moveLayout( + draggedLayoutIndex, + targetLayoutIndex > draggedLayoutIndex + ? targetLayoutIndex - 1 + : targetLayoutIndex + ); + onProjectItemModified(); + } + draggedLayoutIndexRef.current = null; + }, + [project, onProjectItemModified] ); - const newLayout = project.insertNewLayout(newName, index + 1); - newLayout.setName(newName); - newLayout.updateBehaviorsSharedData(project); - addDefaultLightToAllLayers(newLayout); - this._onProjectItemModified(); + const dropOnExternalLayout = React.useCallback( + (targetExternalLayoutIndex: number) => { + const { + current: draggedExternalLayoutIndex, + } = draggedExternalLayoutIndexRef; + if (draggedExternalLayoutIndex === null) return; + + if (targetExternalLayoutIndex !== draggedExternalLayoutIndex) { + project.moveExternalLayout( + draggedExternalLayoutIndex, + targetExternalLayoutIndex > draggedExternalLayoutIndex + ? targetExternalLayoutIndex - 1 + : targetExternalLayoutIndex + ); + onProjectItemModified(); + } + draggedExternalLayoutIndexRef.current = null; + }, + [project, onProjectItemModified] + ); - // Trigger an edit of the name, so that the user can rename the layout easily. - this._onEditName('layout', newName); - }; + const dropOnExternalEvents = React.useCallback( + (targetExternalEventsIndex: number) => { + const { + current: draggedExternalEventsIndex, + } = draggedExternalEventsIndexRef; + if (draggedExternalEventsIndex === null) return; + + if (targetExternalEventsIndex !== draggedExternalEventsIndex) { + project.moveExternalEvents( + draggedExternalEventsIndex, + targetExternalEventsIndex > draggedExternalEventsIndex + ? targetExternalEventsIndex - 1 + : targetExternalEventsIndex + ); + onProjectItemModified(); + } + draggedExternalEventsIndexRef.current = null; + }, + [project, onProjectItemModified] + ); - _onOpenLayoutProperties = (layout: ?gdLayout) => { - this.setState({ editedPropertiesLayout: layout }); - }; + const dropOnExtension = React.useCallback( + (targetExtensionIndex: number) => { + const { current: draggedExtensionIndex } = draggedExtensionIndexRef; + if (draggedExtensionIndex === null) return; + + if (targetExtensionIndex !== draggedExtensionIndex) { + project.moveEventsFunctionsExtension( + draggedExtensionIndex, + targetExtensionIndex > draggedExtensionIndex + ? targetExtensionIndex - 1 + : targetExtensionIndex + ); + onProjectItemModified(); + } + draggedExtensionIndexRef.current = null; + }, + [project, onProjectItemModified] + ); - _onOpenLayoutVariables = (layout: ?gdLayout) => { - this.setState({ editedVariablesLayout: layout }); - }; + const copyExternalEvents = React.useCallback( + (externalEvents: gdExternalEvents) => { + Clipboard.set(EXTERNAL_EVENTS_CLIPBOARD_KIND, { + externalEvents: serializeToJSObject(externalEvents), + name: externalEvents.getName(), + }); + }, + [] + ); - _addExternalEvents = (index: number, i18n: I18nType) => { - const { project } = this.props; + const cutExternalEvents = React.useCallback( + (externalEvents: gdExternalEvents) => { + copyExternalEvents(externalEvents); + onDeleteExternalEvents(externalEvents); + }, + [copyExternalEvents, onDeleteExternalEvents] + ); - const newName = newNameGenerator( - i18n._(t`Untitled external events`), - name => project.hasExternalEventsNamed(name) + const pasteExternalEvents = React.useCallback( + (index: number) => { + if (!Clipboard.has(EXTERNAL_EVENTS_CLIPBOARD_KIND)) return; + + const clipboardContent = Clipboard.get(EXTERNAL_EVENTS_CLIPBOARD_KIND); + const copiedExternalEvents = SafeExtractor.extractObjectProperty( + clipboardContent, + 'externalEvents' + ); + const name = SafeExtractor.extractStringProperty( + clipboardContent, + 'name' + ); + if (!name || !copiedExternalEvents) return; + + const newName = newNameGenerator(name, name => + project.hasExternalEventsNamed(name) + ); + + const newExternalEvents = project.insertNewExternalEvents( + newName, + index + ); + + unserializeFromJSObject( + newExternalEvents, + copiedExternalEvents, + 'unserializeFrom', + project + ); + newExternalEvents.setName(newName); // Unserialization has overwritten the name. + + onProjectItemModified(); + }, + [project, onProjectItemModified] ); - project.insertNewExternalEvents(newName, index + 1); - this._onProjectItemModified(); - // Trigger an edit of the name, so that the user can rename the external events easily. - this._onEditName('external-events', newName); - }; + const duplicateExternalEvents = React.useCallback( + (externalEvents: gdExternalEvents, index: number) => { + copyExternalEvents(externalEvents); + pasteExternalEvents(index); + }, + [copyExternalEvents, pasteExternalEvents] + ); - _addExternalLayout = (index: number, i18n: I18nType) => { - const { project } = this.props; + const moveUpExternalEvents = React.useCallback( + (index: number) => { + if (index <= 0) return; - const newName = newNameGenerator( - i18n._(t`Untitled external layout`), - name => project.hasExternalLayoutNamed(name) + project.swapExternalEvents(index, index - 1); + onProjectItemModified(); + }, + [project, onProjectItemModified] ); - project.insertNewExternalLayout(newName, index + 1); - this._onProjectItemModified(); - - // Trigger an edit of the name, so that the user can rename the external layout easily. - this._onEditName('external-layout', newName); - }; - _addEventsFunctionsExtension = (index: number, i18n: I18nType) => { - const { project } = this.props; + const moveDownExternalEvents = React.useCallback( + (index: number) => { + if (index >= project.getExternalEventsCount() - 1) return; - const newName = newNameGenerator(i18n._(t`UntitledExtension`), name => - isExtensionNameTaken(name, project) + project.swapExternalEvents(index, index + 1); + onProjectItemModified(); + }, + [project, onProjectItemModified] ); - project.insertNewEventsFunctionsExtension(newName, index + 1); - this._onProjectItemModified(); - return newName; - }; - - _moveUpLayout = (index: number) => { - const { project } = this.props; - if (index <= 0) return; - - project.swapLayouts(index, index - 1); - this._onProjectItemModified(); - }; - - _moveDownLayout = (index: number) => { - const { project } = this.props; - if (index >= project.getLayoutsCount() - 1) return; - - project.swapLayouts(index, index + 1); - this._onProjectItemModified(); - }; - - _dropOnLayout = (targetLayoutIndex: number) => { - const { _draggedLayoutIndex } = this; - if (_draggedLayoutIndex === null) return; - - if (targetLayoutIndex !== _draggedLayoutIndex) { - this.props.project.moveLayout( - _draggedLayoutIndex, - targetLayoutIndex > _draggedLayoutIndex - ? targetLayoutIndex - 1 - : targetLayoutIndex - ); - this._onProjectItemModified(); - } - this._draggedLayoutIndex = null; - }; - - _dropOnExternalLayout = (targetExternalLayoutIndex: number) => { - const { _draggedExternalLayoutIndex } = this; - if (_draggedExternalLayoutIndex === null) return; - - if (targetExternalLayoutIndex !== _draggedExternalLayoutIndex) { - this.props.project.moveExternalLayout( - _draggedExternalLayoutIndex, - targetExternalLayoutIndex > _draggedExternalLayoutIndex - ? targetExternalLayoutIndex - 1 - : targetExternalLayoutIndex - ); - this._onProjectItemModified(); - } - this._draggedExternalLayoutIndex = null; - }; - - _dropOnExternalEvents = (targetExternalEventsIndex: number) => { - const { _draggedExternalEventsIndex } = this; - if (_draggedExternalEventsIndex === null) return; - - if (targetExternalEventsIndex !== _draggedExternalEventsIndex) { - this.props.project.moveExternalEvents( - _draggedExternalEventsIndex, - targetExternalEventsIndex > _draggedExternalEventsIndex - ? targetExternalEventsIndex - 1 - : targetExternalEventsIndex - ); - this._onProjectItemModified(); - } - this._draggedExternalEventsIndex = null; - }; - - _dropOnExtension = (targetExtensionIndex: number) => { - const { _draggedExtensionIndex } = this; - if (_draggedExtensionIndex === null) return; - - if (targetExtensionIndex !== _draggedExtensionIndex) { - this.props.project.moveEventsFunctionsExtension( - _draggedExtensionIndex, - targetExtensionIndex > _draggedExtensionIndex - ? targetExtensionIndex - 1 - : targetExtensionIndex - ); - this._onProjectItemModified(); - } - this._draggedExtensionIndex = null; - }; - - _copyExternalEvents = (externalEvents: gdExternalEvents) => { - Clipboard.set(EXTERNAL_EVENTS_CLIPBOARD_KIND, { - externalEvents: serializeToJSObject(externalEvents), - name: externalEvents.getName(), - }); - }; - _cutExternalEvents = (externalEvents: gdExternalEvents) => { - this._copyExternalEvents(externalEvents); - this.props.onDeleteExternalEvents(externalEvents); - }; - - _pasteExternalEvents = (index: number) => { - if (!Clipboard.has(EXTERNAL_EVENTS_CLIPBOARD_KIND)) return; + const copyExternalLayout = React.useCallback( + (externalLayout: gdExternalLayout) => { + Clipboard.set(EXTERNAL_LAYOUT_CLIPBOARD_KIND, { + externalLayout: serializeToJSObject(externalLayout), + name: externalLayout.getName(), + }); + }, + [] + ); - const clipboardContent = Clipboard.get(EXTERNAL_EVENTS_CLIPBOARD_KIND); - const copiedExternalEvents = SafeExtractor.extractObjectProperty( - clipboardContent, - 'externalEvents' + const cutExternalLayout = React.useCallback( + (externalLayout: gdExternalLayout) => { + copyExternalLayout(externalLayout); + onDeleteExternalLayout(externalLayout); + }, + [copyExternalLayout, onDeleteExternalLayout] ); - const name = SafeExtractor.extractStringProperty(clipboardContent, 'name'); - if (!name || !copiedExternalEvents) return; - const { project } = this.props; + const pasteExternalLayout = React.useCallback( + (index: number) => { + if (!Clipboard.has(EXTERNAL_LAYOUT_CLIPBOARD_KIND)) return; + + const clipboardContent = Clipboard.get(EXTERNAL_LAYOUT_CLIPBOARD_KIND); + const copiedExternalLayout = SafeExtractor.extractObjectProperty( + clipboardContent, + 'externalLayout' + ); + const name = SafeExtractor.extractStringProperty( + clipboardContent, + 'name' + ); + if (!name || !copiedExternalLayout) return; + + const newName = newNameGenerator(name, name => + project.hasExternalLayoutNamed(name) + ); + + const newExternalLayout = project.insertNewExternalLayout( + newName, + index + ); + + unserializeFromJSObject(newExternalLayout, copiedExternalLayout); + newExternalLayout.setName(newName); // Unserialization has overwritten the name. + onProjectItemModified(); + }, + [project, onProjectItemModified] + ); - const newName = newNameGenerator(name, name => - project.hasExternalEventsNamed(name) + const duplicateExternalLayout = React.useCallback( + (externalLayout: gdExternalLayout, index: number) => { + copyExternalLayout(externalLayout); + pasteExternalLayout(index); + }, + [copyExternalLayout, pasteExternalLayout] ); - const newExternalEvents = project.insertNewExternalEvents(newName, index); + const moveUpExternalLayout = React.useCallback( + (index: number) => { + if (index <= 0) return; - unserializeFromJSObject( - newExternalEvents, - copiedExternalEvents, - 'unserializeFrom', - project + project.swapExternalLayouts(index, index - 1); + onProjectItemModified(); + }, + [project, onProjectItemModified] ); - newExternalEvents.setName(newName); // Unserialization has overwritten the name. - - this._onProjectItemModified(); - }; - - _duplicateExternalEvents = ( - externalEvents: gdExternalEvents, - index: number - ) => { - this._copyExternalEvents(externalEvents); - this._pasteExternalEvents(index); - }; - - _moveUpExternalEvents = (index: number) => { - const { project } = this.props; - if (index <= 0) return; - - project.swapExternalEvents(index, index - 1); - this._onProjectItemModified(); - }; - - _moveDownExternalEvents = (index: number) => { - const { project } = this.props; - if (index >= project.getExternalEventsCount() - 1) return; - - project.swapExternalEvents(index, index + 1); - this._onProjectItemModified(); - }; - - _copyExternalLayout = (externalLayout: gdExternalLayout) => { - Clipboard.set(EXTERNAL_LAYOUT_CLIPBOARD_KIND, { - externalLayout: serializeToJSObject(externalLayout), - name: externalLayout.getName(), - }); - }; - - _cutExternalLayout = (externalLayout: gdExternalLayout) => { - this._copyExternalLayout(externalLayout); - this.props.onDeleteExternalLayout(externalLayout); - }; - _pasteExternalLayout = (index: number) => { - if (!Clipboard.has(EXTERNAL_LAYOUT_CLIPBOARD_KIND)) return; + const moveDownExternalLayout = React.useCallback( + (index: number) => { + if (index >= project.getExternalLayoutsCount() - 1) return; - const clipboardContent = Clipboard.get(EXTERNAL_LAYOUT_CLIPBOARD_KIND); - const copiedExternalLayout = SafeExtractor.extractObjectProperty( - clipboardContent, - 'externalLayout' + project.swapExternalLayouts(index, index + 1); + onProjectItemModified(); + }, + [project, onProjectItemModified] ); - const name = SafeExtractor.extractStringProperty(clipboardContent, 'name'); - if (!name || !copiedExternalLayout) return; - const { project } = this.props; + const copyEventsFunctionsExtension = React.useCallback( + (eventsFunctionsExtension: gdEventsFunctionsExtension) => { + Clipboard.set(EVENTS_FUNCTIONS_EXTENSION_CLIPBOARD_KIND, { + eventsFunctionsExtension: serializeToJSObject( + eventsFunctionsExtension + ), + name: eventsFunctionsExtension.getName(), + }); + }, + [] + ); - const newName = newNameGenerator(name, name => - project.hasExternalLayoutNamed(name) + const cutEventsFunctionsExtension = React.useCallback( + (eventsFunctionsExtension: gdEventsFunctionsExtension) => { + copyEventsFunctionsExtension(eventsFunctionsExtension); + onDeleteEventsFunctionsExtension(eventsFunctionsExtension); + }, + [copyEventsFunctionsExtension, onDeleteEventsFunctionsExtension] ); - const newExternalLayout = project.insertNewExternalLayout(newName, index); - - unserializeFromJSObject(newExternalLayout, copiedExternalLayout); - newExternalLayout.setName(newName); // Unserialization has overwritten the name. - this._onProjectItemModified(); - }; - - _duplicateExternalLayout = ( - externalLayout: gdExternalLayout, - index: number - ) => { - this._copyExternalLayout(externalLayout); - this._pasteExternalLayout(index); - }; - - _moveUpExternalLayout = (index: number) => { - const { project } = this.props; - if (index <= 0) return; - - project.swapExternalLayouts(index, index - 1); - this._onProjectItemModified(); - }; - - _moveDownExternalLayout = (index: number) => { - const { project } = this.props; - if (index >= project.getExternalLayoutsCount() - 1) return; - - project.swapExternalLayouts(index, index + 1); - this._onProjectItemModified(); - }; - - _copyEventsFunctionsExtension = ( - eventsFunctionsExtension: gdEventsFunctionsExtension - ) => { - Clipboard.set(EVENTS_FUNCTIONS_EXTENSION_CLIPBOARD_KIND, { - eventsFunctionsExtension: serializeToJSObject(eventsFunctionsExtension), - name: eventsFunctionsExtension.getName(), - }); - }; - - _cutEventsFunctionsExtension = ( - eventsFunctionsExtension: gdEventsFunctionsExtension - ) => { - this._copyEventsFunctionsExtension(eventsFunctionsExtension); - this.props.onDeleteEventsFunctionsExtension(eventsFunctionsExtension); - }; - - _duplicateEventsFunctionsExtension = ( - eventsFunctionsExtension: gdEventsFunctionsExtension, - index: number - ) => { - this._copyEventsFunctionsExtension(eventsFunctionsExtension); - this._pasteEventsFunctionsExtension(index); - }; - - _pasteEventsFunctionsExtension = (index: number) => { - if (!Clipboard.has(EVENTS_FUNCTIONS_EXTENSION_CLIPBOARD_KIND)) return; - - const clipboardContent = Clipboard.get( - EVENTS_FUNCTIONS_EXTENSION_CLIPBOARD_KIND + const pasteEventsFunctionsExtension = React.useCallback( + (index: number) => { + if (!Clipboard.has(EVENTS_FUNCTIONS_EXTENSION_CLIPBOARD_KIND)) return; + + const clipboardContent = Clipboard.get( + EVENTS_FUNCTIONS_EXTENSION_CLIPBOARD_KIND + ); + const copiedEventsFunctionsExtension = SafeExtractor.extractObjectProperty( + clipboardContent, + 'eventsFunctionsExtension' + ); + const name = SafeExtractor.extractStringProperty( + clipboardContent, + 'name' + ); + if (!name || !copiedEventsFunctionsExtension) return; + + const newName = newNameGenerator(name, name => + isExtensionNameTaken(name, project) + ); + + const newEventsFunctionsExtension = project.insertNewEventsFunctionsExtension( + newName, + index + ); + + unserializeFromJSObject( + newEventsFunctionsExtension, + copiedEventsFunctionsExtension, + 'unserializeFrom', + project + ); + newEventsFunctionsExtension.setName(newName); // Unserialization has overwritten the name. + + onProjectItemModified(); + onReloadEventsFunctionsExtensions(); + }, + [project, onProjectItemModified, onReloadEventsFunctionsExtensions] ); - const copiedEventsFunctionsExtension = SafeExtractor.extractObjectProperty( - clipboardContent, - 'eventsFunctionsExtension' + + const duplicateEventsFunctionsExtension = React.useCallback( + (eventsFunctionsExtension: gdEventsFunctionsExtension, index: number) => { + copyEventsFunctionsExtension(eventsFunctionsExtension); + pasteEventsFunctionsExtension(index); + }, + [copyEventsFunctionsExtension, pasteEventsFunctionsExtension] ); - const name = SafeExtractor.extractStringProperty(clipboardContent, 'name'); - if (!name || !copiedEventsFunctionsExtension) return; - const { project } = this.props; + const moveUpEventsFunctionsExtension = React.useCallback( + (index: number) => { + if (index <= 0) return; - const newName = newNameGenerator(name, name => - isExtensionNameTaken(name, project) + project.swapEventsFunctionsExtensions(index, index - 1); + onProjectItemModified(); + }, + [project, onProjectItemModified] ); - const newEventsFunctionsExtension = project.insertNewEventsFunctionsExtension( - newName, - index - ); + const moveDownEventsFunctionsExtension = React.useCallback( + (index: number) => { + if (index >= project.getEventsFunctionsExtensionsCount() - 1) return; - unserializeFromJSObject( - newEventsFunctionsExtension, - copiedEventsFunctionsExtension, - 'unserializeFrom', - project + project.swapEventsFunctionsExtensions(index, index + 1); + onProjectItemModified(); + }, + [project, onProjectItemModified] ); - newEventsFunctionsExtension.setName(newName); // Unserialization has overwritten the name. - - this._onProjectItemModified(); - this.props.onReloadEventsFunctionsExtensions(); - }; - - _moveUpEventsFunctionsExtension = (index: number) => { - const { project } = this.props; - if (index <= 0) return; - - project.swapEventsFunctionsExtensions(index, index - 1); - this._onProjectItemModified(); - }; - - _moveDownEventsFunctionsExtension = (index: number) => { - const { project } = this.props; - if (index >= project.getEventsFunctionsExtensionsCount() - 1) return; - - project.swapEventsFunctionsExtensions(index, index + 1); - this._onProjectItemModified(); - }; - - _onEditEventsFunctionExtensionOrSeeDetails = ( - extensionShortHeadersByName: { [string]: ExtensionShortHeader }, - eventsFunctionsExtension: gdEventsFunctionsExtension, - name: string - ) => { - // If the extension is coming from the store, open its details. - // If that's not the case, or if it cannot be found in the store, edit it directly. - const originName = eventsFunctionsExtension.getOriginName(); - if (originName !== 'gdevelop-extension-store') { - this.props.onOpenEventsFunctionsExtension(name); - return; - } - const originIdentifier = eventsFunctionsExtension.getOriginIdentifier(); - const extensionShortHeader = extensionShortHeadersByName[originIdentifier]; - if (!extensionShortHeader) { - console.warn( - `This extension was downloaded from the store but its reference ${originIdentifier} couldn't be found in the store. Opening the extension in the editor...` - ); - this.props.onOpenEventsFunctionsExtension(name); - return; - } - this.setState({ - openedExtensionShortHeader: extensionShortHeader, - openedExtensionName: name, - }); - }; - _onProjectPropertiesApplied = (options: { newName?: string }) => { - if (this.props.unsavedChanges) { - this.props.unsavedChanges.triggerUnsavedChanges(); - } + const onEditEventsFunctionExtensionOrSeeDetails = React.useCallback( + ( + extensionShortHeadersByName: { [string]: ExtensionShortHeader }, + eventsFunctionsExtension: gdEventsFunctionsExtension, + name: string + ) => { + // If the extension is coming from the store, open its details. + // If that's not the case, or if it cannot be found in the store, edit it directly. + const originName = eventsFunctionsExtension.getOriginName(); + if (originName !== 'gdevelop-extension-store') { + onOpenEventsFunctionsExtension(name); + return; + } + const originIdentifier = eventsFunctionsExtension.getOriginIdentifier(); + const extensionShortHeader = + extensionShortHeadersByName[originIdentifier]; + if (!extensionShortHeader) { + console.warn( + `This extension was downloaded from the store but its reference ${originIdentifier} couldn't be found in the store. Opening the extension in the editor...` + ); + onOpenEventsFunctionsExtension(name); + return; + } + setOpenedExtensionShortHeader(extensionShortHeader); + setOpenedExtensionName(name); + }, + [onOpenEventsFunctionsExtension] + ); - if (options.newName) { - this.props.onChangeProjectName(options.newName); - } + const onProjectPropertiesApplied = React.useCallback( + (options: { newName?: string }) => { + if (unsavedChanges) { + unsavedChanges.triggerUnsavedChanges(); + } + + if (options.newName) { + onChangeProjectName(options.newName); + } + setProjectPropertiesDialogOpen(false); + }, + [unsavedChanges, onChangeProjectName] + ); - this.setState({ projectPropertiesDialogOpen: false }); - }; + const onRequestSearch = () => { + /* Do nothing for now, but we could open the first result. */ + }; - _onSearchChange = (text: string) => - this.setState({ - searchText: text, - }); + const setProjectFirstLayout = React.useCallback( + (layoutName: string) => { + project.setFirstLayout(layoutName); + forceUpdate(); + }, + [project, forceUpdate] + ); - _onRequestSearch = () => { - /* Do nothing for now, but we could open the first result. */ - }; - - _onProjectItemModified = () => { - this.forceUpdate(); - if (this.props.unsavedChanges) - this.props.unsavedChanges.triggerUnsavedChanges(); - }; - - _setProjectFirstLayout = (layoutName: string) => { - this.props.project.setFirstLayout(layoutName); - this.forceUpdate(); - }; - - _onCreateNewExtension = (project: gdProject, i18n: I18nType) => { - const newExtensionName = this._addEventsFunctionsExtension( - project.getEventsFunctionsExtensionsCount(), - i18n + const onCreateNewExtension = React.useCallback( + (project: gdProject, i18n: I18nType) => { + const newExtensionName = addEventsFunctionsExtension( + project.getEventsFunctionsExtensionsCount(), + i18n + ); + onOpenEventsFunctionsExtension(newExtensionName); + setExtensionsSearchDialogOpen(false); + }, + [addEventsFunctionsExtension, onOpenEventsFunctionsExtension] ); - this.props.onOpenEventsFunctionsExtension(newExtensionName); - this.setState({ extensionsSearchDialogOpen: false }); - }; - - render() { - const { - project, - eventsFunctionsExtensionsError, - onReloadEventsFunctionsExtensions, - onInstallExtension, - shortcutMap, - } = this.props; - const { - renamedItemKind, - renamedItemName, - searchText, - openedExtensionShortHeader, - openedExtensionName, - } = this.state; const firstLayoutName = project.getFirstLayout(); @@ -747,74 +835,87 @@ class ProjectManager extends React.Component { searchText ); + const onOpenGamesDashboardDialog = gameMatchingProjectUuid + ? () => setOpenGameDetails(true) + : null; + return ( {({ i18n }) => (
(this._searchBar = searchBar)} + ref={searchBarRef} value={searchText} - onRequestSearch={this._onRequestSearch} - onChange={this._onSearchChange} + onRequestSearch={onRequestSearch} + onChange={setSearchText} placeholder={t`Search in project`} />
- Game settings} renderNestedItems={() => [ Properties} - leftIcon={} - onClick={this._openProjectProperties} + leftIcon={} + onClick={openProjectProperties} noPadding />, Global variables} - leftIcon={} - onClick={this._openProjectVariables} + id={getProjectManagerItemId('game-icons')} + key="icons" + primaryText={Icons and thumbnail} + leftIcon={} + onClick={onOpenPlatformSpecificAssets} noPadding />, + , + ]} + /> + Project settings} + renderNestedItems={() => [ Icons and thumbnail} - leftIcon={} - onClick={this.props.onOpenPlatformSpecificAssets} + id={getProjectManagerItemId('global-variables')} + key="global-variables" + primaryText={Global variables} + leftIcon={} + onClick={openProjectVariables} noPadding />, Resources} - leftIcon={} - onClick={this.props.onOpenResources} + leftIcon={} + onClick={onOpenResources} noPadding />, ]} /> Scenes} renderNestedItems={() => [ ...displayedScenes.map((layout: gdLayout, i: number) => { @@ -848,48 +949,48 @@ class ProjectManager extends React.Component { renamedItemKind === 'layout' && renamedItemName === name } - onEdit={() => this.props.onOpenLayout(name)} - onDelete={() => this.props.onDeleteLayout(layout)} + onEdit={() => onOpenLayout(name)} + onDelete={() => onDeleteLayout(layout)} addLabel={t`Add a new scene`} - onAdd={() => this._addLayout(i, i18n)} + onAdd={() => addLayout(i, i18n)} onRename={newName => { - this.props.onRenameLayout(name, newName); - this._onEditName(null, ''); + onRenameLayout(name, newName); + onEditName(null, ''); }} - onEditName={() => this._onEditName('layout', name)} - onCopy={() => this._copyLayout(layout)} - onCut={() => this._cutLayout(layout)} - onPaste={() => this._pasteLayout(i)} - onDuplicate={() => this._duplicateLayout(layout, i)} + onEditName={() => onEditName('layout', name)} + onCopy={() => copyLayout(layout)} + onCut={() => cutLayout(layout)} + onPaste={() => pasteLayout(i)} + onDuplicate={() => duplicateLayout(layout, i)} canPaste={() => Clipboard.has(LAYOUT_CLIPBOARD_KIND)} canMoveUp={i !== 0} - onMoveUp={() => this._moveUpLayout(i)} + onMoveUp={() => moveUpLayout(i)} canMoveDown={i !== project.getLayoutsCount() - 1} - onMoveDown={() => this._moveDownLayout(i)} + onMoveDown={() => moveDownLayout(i)} dragAndDropProps={{ DragSourceAndDropTarget: DragSourceAndDropTargetForScenes, onBeginDrag: () => { - this._draggedLayoutIndex = i; + draggedLayoutIndexRef.current = i; }, onDrop: () => { - this._dropOnLayout(i); + dropOnLayout(i); }, }} buildExtraMenuTemplate={(i18n: I18nType) => [ { label: i18n._(t`Edit scene properties`), enabled: true, - click: () => this._onOpenLayoutProperties(layout), + click: () => onOpenLayoutProperties(layout), }, { label: i18n._(t`Edit scene variables`), enabled: true, - click: () => this._onOpenLayoutVariables(layout), + click: () => onOpenLayoutVariables(layout), }, { label: i18n._(t`Set as start scene`), enabled: name !== firstLayoutName, - click: () => this._setProjectFirstLayout(name), + click: () => setProjectFirstLayout(name), }, ]} /> @@ -903,7 +1004,7 @@ class ProjectManager extends React.Component { id="add-new-scene-button" key={'add-scene'} onClick={() => - this._addLayout(project.getLayoutsCount(), i18n) + addLayout(project.getLayoutsCount(), i18n) } primaryText={Add scene} />, @@ -911,7 +1012,7 @@ class ProjectManager extends React.Component { ]} /> Extensions} error={eventsFunctionsExtensionsError} onRefresh={onReloadEventsFunctionsExtensions} @@ -928,43 +1029,36 @@ class ProjectManager extends React.Component { renamedItemName === name } onEdit={extensionShortHeadersByName => - this._onEditEventsFunctionExtensionOrSeeDetails( + onEditEventsFunctionExtensionOrSeeDetails( extensionShortHeadersByName, eventsFunctionsExtension, name ) } onDelete={() => - this.props.onDeleteEventsFunctionsExtension( + onDeleteEventsFunctionsExtension( eventsFunctionsExtension ) } onAdd={() => { - this._addEventsFunctionsExtension(i, i18n); + addEventsFunctionsExtension(i, i18n); }} onRename={newName => { - this.props.onRenameEventsFunctionsExtension( - name, - newName - ); - this._onEditName(null, ''); + onRenameEventsFunctionsExtension(name, newName); + onEditName(null, ''); }} onEditName={() => - this._onEditName('events-functions-extension', name) + onEditName('events-functions-extension', name) } onCopy={() => - this._copyEventsFunctionsExtension( - eventsFunctionsExtension - ) + copyEventsFunctionsExtension(eventsFunctionsExtension) } onCut={() => - this._cutEventsFunctionsExtension( - eventsFunctionsExtension - ) + cutEventsFunctionsExtension(eventsFunctionsExtension) } - onPaste={() => this._pasteEventsFunctionsExtension(i)} + onPaste={() => pasteEventsFunctionsExtension(i)} onDuplicate={() => - this._duplicateEventsFunctionsExtension( + duplicateEventsFunctionsExtension( eventsFunctionsExtension, i ) @@ -975,20 +1069,18 @@ class ProjectManager extends React.Component { ) } canMoveUp={i !== 0} - onMoveUp={() => this._moveUpEventsFunctionsExtension(i)} + onMoveUp={() => moveUpEventsFunctionsExtension(i)} canMoveDown={ i !== project.getEventsFunctionsExtensionsCount() - 1 } - onMoveDown={() => - this._moveDownEventsFunctionsExtension(i) - } + onMoveDown={() => moveDownEventsFunctionsExtension(i)} dragAndDropProps={{ DragSourceAndDropTarget: DragSourceAndDropTargetForExtensions, onBeginDrag: () => { - this._draggedExtensionIndex = i; + draggedExtensionIndexRef.current = i; }, onDrop: () => { - this._dropOnExtension(i); + dropOnExtension(i); }, }} /> @@ -1005,13 +1097,13 @@ class ProjectManager extends React.Component { primaryText={ Create or search for new extensions } - onClick={this._openSearchExtensionDialog} + onClick={openSearchExtensionDialog} />, ]), ]} /> External events} renderNestedItems={() => [ ...displayedExternalEvents.map((externalEvents, i) => { @@ -1026,39 +1118,35 @@ class ProjectManager extends React.Component { renamedItemKind === 'external-events' && renamedItemName === name } - onEdit={() => this.props.onOpenExternalEvents(name)} - onDelete={() => - this.props.onDeleteExternalEvents(externalEvents) - } + onEdit={() => onOpenExternalEvents(name)} + onDelete={() => onDeleteExternalEvents(externalEvents)} addLabel={t`Add new external events`} - onAdd={() => this._addExternalEvents(i, i18n)} + onAdd={() => addExternalEvents(i, i18n)} onRename={newName => { - this.props.onRenameExternalEvents(name, newName); - this._onEditName(null, ''); + onRenameExternalEvents(name, newName); + onEditName(null, ''); }} - onEditName={() => - this._onEditName('external-events', name) - } - onCopy={() => this._copyExternalEvents(externalEvents)} - onCut={() => this._cutExternalEvents(externalEvents)} - onPaste={() => this._pasteExternalEvents(i)} + onEditName={() => onEditName('external-events', name)} + onCopy={() => copyExternalEvents(externalEvents)} + onCut={() => cutExternalEvents(externalEvents)} + onPaste={() => pasteExternalEvents(i)} onDuplicate={() => - this._duplicateExternalEvents(externalEvents, i) + duplicateExternalEvents(externalEvents, i) } canPaste={() => Clipboard.has(EXTERNAL_EVENTS_CLIPBOARD_KIND) } canMoveUp={i !== 0} - onMoveUp={() => this._moveUpExternalEvents(i)} + onMoveUp={() => moveUpExternalEvents(i)} canMoveDown={i !== project.getExternalEventsCount() - 1} - onMoveDown={() => this._moveDownExternalEvents(i)} + onMoveDown={() => moveDownExternalEvents(i)} dragAndDropProps={{ DragSourceAndDropTarget: DragSourceAndDropTargetForExternalEvents, onBeginDrag: () => { - this._draggedExternalEventsIndex = i; + draggedExternalEventsIndexRef.current = i; }, onDrop: () => { - this._dropOnExternalEvents(i); + dropOnExternalEvents(i); }, }} /> @@ -1072,7 +1160,7 @@ class ProjectManager extends React.Component { key={'add-external-events'} primaryText={Add external events} onClick={() => - this._addExternalEvents( + addExternalEvents( project.getExternalEventsCount(), i18n ) @@ -1082,7 +1170,7 @@ class ProjectManager extends React.Component { ]} /> External layouts} renderNestedItems={() => [ ...displayedExternalLayouts.map((externalLayout, i) => { @@ -1097,41 +1185,37 @@ class ProjectManager extends React.Component { renamedItemKind === 'external-layout' && renamedItemName === name } - onEdit={() => this.props.onOpenExternalLayout(name)} - onDelete={() => - this.props.onDeleteExternalLayout(externalLayout) - } + onEdit={() => onOpenExternalLayout(name)} + onDelete={() => onDeleteExternalLayout(externalLayout)} addLabel={t`Add a new external layout`} - onAdd={() => this._addExternalLayout(i, i18n)} + onAdd={() => addExternalLayout(i, i18n)} onRename={newName => { - this.props.onRenameExternalLayout(name, newName); - this._onEditName(null, ''); + onRenameExternalLayout(name, newName); + onEditName(null, ''); }} - onEditName={() => - this._onEditName('external-layout', name) - } - onCopy={() => this._copyExternalLayout(externalLayout)} - onCut={() => this._cutExternalLayout(externalLayout)} - onPaste={() => this._pasteExternalLayout(i)} + onEditName={() => onEditName('external-layout', name)} + onCopy={() => copyExternalLayout(externalLayout)} + onCut={() => cutExternalLayout(externalLayout)} + onPaste={() => pasteExternalLayout(i)} onDuplicate={() => - this._duplicateExternalLayout(externalLayout, i) + duplicateExternalLayout(externalLayout, i) } canPaste={() => Clipboard.has(EXTERNAL_LAYOUT_CLIPBOARD_KIND) } canMoveUp={i !== 0} - onMoveUp={() => this._moveUpExternalLayout(i)} + onMoveUp={() => moveUpExternalLayout(i)} canMoveDown={ i !== project.getExternalLayoutsCount() - 1 } - onMoveDown={() => this._moveDownExternalLayout(i)} + onMoveDown={() => moveDownExternalLayout(i)} dragAndDropProps={{ DragSourceAndDropTarget: DragSourceAndDropTargetForExternalLayouts, onBeginDrag: () => { - this._draggedExternalLayoutIndex = i; + draggedExternalLayoutIndexRef.current = i; }, onDrop: () => { - this._dropOnExternalLayout(i); + dropOnExternalLayout(i); }, }} /> @@ -1145,7 +1229,7 @@ class ProjectManager extends React.Component { key={'add-external-layout'} primaryText={Add external layout} onClick={() => - this._addExternalLayout( + addExternalLayout( project.getExternalLayoutsCount(), i18n ) @@ -1155,19 +1239,16 @@ class ProjectManager extends React.Component { ]} /> - {this.state.projectVariablesEditorOpen && ( + {projectVariablesEditorOpen && ( Global Variables} open variablesContainer={project.getVariables()} - onCancel={() => - this.setState({ projectVariablesEditorOpen: false }) - } + onCancel={() => setProjectVariablesEditorOpen(false)} onApply={() => { - if (this.props.unsavedChanges) - this.props.unsavedChanges.triggerUnsavedChanges(); - this.setState({ projectVariablesEditorOpen: false }); + if (unsavedChanges) unsavedChanges.triggerUnsavedChanges(); + setProjectVariablesEditorOpen(false); }} emptyPlaceholderTitle={ Add your first global variable @@ -1178,9 +1259,7 @@ class ProjectManager extends React.Component { } helpPagePath={'/all-features/variables/global-variables'} - hotReloadPreviewButtonProps={ - this.props.hotReloadPreviewButtonProps - } + hotReloadPreviewButtonProps={hotReloadPreviewButtonProps} onComputeAllVariableNames={() => EventsRootVariablesFinder.findAllGlobalVariables( project.getCurrentPlatform(), @@ -1189,94 +1268,94 @@ class ProjectManager extends React.Component { } /> )} - {this.state.projectPropertiesDialogOpen && ( + {projectPropertiesDialogOpen && ( - this.setState({ projectPropertiesDialogOpen: false }) - } - onApply={this.props.onSaveProjectProperties} - onPropertiesApplied={this._onProjectPropertiesApplied} - resourceManagementProps={this.props.resourceManagementProps} - hotReloadPreviewButtonProps={ - this.props.hotReloadPreviewButtonProps - } + onClose={() => setProjectPropertiesDialogOpen(false)} + onApply={onSaveProjectProperties} + onPropertiesApplied={onProjectPropertiesApplied} + resourceManagementProps={resourceManagementProps} + hotReloadPreviewButtonProps={hotReloadPreviewButtonProps} i18n={i18n} /> )} - {!!this.state.editedPropertiesLayout && ( + {!!editedPropertiesLayout && ( { - if (this.props.unsavedChanges) - this.props.unsavedChanges.triggerUnsavedChanges(); - this._onOpenLayoutProperties(null); + if (unsavedChanges) unsavedChanges.triggerUnsavedChanges(); + onOpenLayoutProperties(null); }} - onClose={() => this._onOpenLayoutProperties(null)} + onClose={() => onOpenLayoutProperties(null)} onEditVariables={() => { - this._onOpenLayoutVariables( - this.state.editedPropertiesLayout - ); - this._onOpenLayoutProperties(null); + onOpenLayoutVariables(editedPropertiesLayout); + onOpenLayoutProperties(null); }} - resourceManagementProps={this.props.resourceManagementProps} + resourceManagementProps={resourceManagementProps} /> )} - {!!this.state.editedVariablesLayout && ( + {!!editedVariablesLayout && ( this._onOpenLayoutVariables(null)} + layout={editedVariablesLayout} + onClose={() => onOpenLayoutVariables(null)} onApply={() => { - if (this.props.unsavedChanges) - this.props.unsavedChanges.triggerUnsavedChanges(); - this._onOpenLayoutVariables(null); + if (unsavedChanges) unsavedChanges.triggerUnsavedChanges(); + onOpenLayoutVariables(null); }} - hotReloadPreviewButtonProps={ - this.props.hotReloadPreviewButtonProps - } + hotReloadPreviewButtonProps={hotReloadPreviewButtonProps} /> )} - {this.state.extensionsSearchDialogOpen && ( + {extensionsSearchDialogOpen && ( - this.setState({ extensionsSearchDialogOpen: false }) - } + onClose={() => setExtensionsSearchDialogOpen(false)} onInstallExtension={onInstallExtension} onCreateNew={() => { - this._onCreateNewExtension(project, i18n); + onCreateNewExtension(project, i18n); }} /> )} {openedExtensionShortHeader && openedExtensionName && ( - this.setState({ - openedExtensionShortHeader: null, - openedExtensionName: null, - }) - } - onOpenEventsFunctionsExtension={ - this.props.onOpenEventsFunctionsExtension - } + onClose={() => { + setOpenedExtensionShortHeader(null); + setOpenedExtensionName(null); + }} + onOpenEventsFunctionsExtension={onOpenEventsFunctionsExtension} extensionShortHeader={openedExtensionShortHeader} extensionName={openedExtensionName} onInstallExtension={onInstallExtension} /> )} + {openGameDetails && gameMatchingProjectUuid && ( + setOpenGameDetails(false)} + onGameDeleted={() => { + setOpenGameDetails(false); + fetchGames(); + }} + onGameUpdated={() => { + fetchGames(); + }} + /> + )}
)}
); - } -} + }, + (prevProps, nextProps) => nextProps.freezeUpdate +); const ProjectManagerWithErrorBoundary = (props: Props) => ( ( + + + + + + +)); diff --git a/newIDE/app/src/UI/CustomSvgIcons/Picture.js b/newIDE/app/src/UI/CustomSvgIcons/Picture.js new file mode 100644 index 000000000000..f0b1dcf5ac63 --- /dev/null +++ b/newIDE/app/src/UI/CustomSvgIcons/Picture.js @@ -0,0 +1,13 @@ +import React from 'react'; +import SvgIcon from '@material-ui/core/SvgIcon'; + +export default React.memo(props => ( + + + +)); diff --git a/newIDE/app/src/UI/CustomSvgIcons/ProjectResources.js b/newIDE/app/src/UI/CustomSvgIcons/ProjectResources.js new file mode 100644 index 000000000000..2a5f937c80c6 --- /dev/null +++ b/newIDE/app/src/UI/CustomSvgIcons/ProjectResources.js @@ -0,0 +1,19 @@ +import React from 'react'; +import SvgIcon from '@material-ui/core/SvgIcon'; + +export default React.memo(props => ( + + + + +)); diff --git a/newIDE/app/src/UI/CustomSvgIcons/Settings.js b/newIDE/app/src/UI/CustomSvgIcons/Settings.js new file mode 100644 index 000000000000..592826e0aa00 --- /dev/null +++ b/newIDE/app/src/UI/CustomSvgIcons/Settings.js @@ -0,0 +1,11 @@ +import React from 'react'; +import SvgIcon from '@material-ui/core/SvgIcon'; + +export default React.memo(props => ( + + + +)); diff --git a/newIDE/app/src/UI/ErrorBoundary.js b/newIDE/app/src/UI/ErrorBoundary.js index 4745fd14a03e..a10beaa0f0e9 100644 --- a/newIDE/app/src/UI/ErrorBoundary.js +++ b/newIDE/app/src/UI/ErrorBoundary.js @@ -44,6 +44,7 @@ type ErrorBoundaryScope = | 'start-page-play' | 'start-page-community' | 'start-page-team' + | 'start-page-manage' | 'about' | 'preferences' | 'profile' diff --git a/newIDE/app/src/UI/HighlightingTooltip.js b/newIDE/app/src/UI/HighlightingTooltip.js new file mode 100644 index 000000000000..e472afd83416 --- /dev/null +++ b/newIDE/app/src/UI/HighlightingTooltip.js @@ -0,0 +1,198 @@ +// @flow + +import * as React from 'react'; +import Text from './Text'; +import { Line } from './Grid'; +import { getDisplayZIndexForHighlighter } from '../InAppTutorial/HTMLUtils'; +import ClickAwayListener from '@material-ui/core/ClickAwayListener'; +import Fade from '@material-ui/core/Fade'; +import Paper from '@material-ui/core/Paper'; +import Popper from '@material-ui/core/Popper'; +import { makeStyles } from '@material-ui/core/styles'; +import { CorsAwareImage } from './CorsAwareImage'; +import IconButton from './IconButton'; +import Cross from './CustomSvgIcons/Cross'; +import { ColumnStackLayout } from './Layout'; +import GDevelopThemeContext from './Theme/GDevelopThemeContext'; +import InAppTutorialContext from '../InAppTutorial/InAppTutorialContext'; + +const styles = { + paper: { + padding: '8px 10px', + minWidth: 180, + }, +}; + +const useClasses = makeStyles({ + popper: { + '&[x-placement*="bottom"] #new-feature-popper-arrow': { + top: 0, + left: 0, + marginTop: '-0.71em', + marginLeft: 4, + marginRight: 4, + '&::before': { + transformOrigin: '0 100%', + }, + }, + '&[x-placement*="top"] #new-feature-popper-arrow': { + bottom: 0, + left: 0, + marginBottom: '-0.71em', + marginLeft: 4, + marginRight: 4, + '&::before': { + transformOrigin: '100% 0', + }, + }, + '&[x-placement*="right"] #new-feature-popper-arrow': { + left: 0, + marginLeft: '-0.71em', + height: '1em', + width: '0.71em', + marginTop: 4, + marginBottom: 4, + '&::before': { + transformOrigin: '100% 100%', + }, + }, + '&[x-placement*="left"] #new-feature-popper-arrow': { + right: 0, + marginRight: '-0.71em', + height: '1em', + width: '0.71em', + marginTop: 4, + marginBottom: 4, + '&::before': { + transformOrigin: '0 0', + }, + }, + }, + arrow: { + overflow: 'hidden', + position: 'absolute', + width: '1em', + /* = width / sqrt(2) = (length of the hypotenuse) */ + height: '0.71em', + boxSizing: 'border-box', + '&::before': { + content: '""', + margin: 'auto', + display: 'block', + width: '100%', + height: '100%', + backgroundColor: 'currentColor', + transform: 'rotate(45deg)', + }, + }, +}); + +type Props = {| + title: React.Node, + thumbnailSource?: string, + thumbnailAlt?: string, + content: React.Node, + anchorElement: HTMLElement, + onClose: () => void, + placement: 'left' | 'top' | 'bottom' | 'right', + closeWithBackdropClick: boolean, +|}; + +const HighlightingTooltip = ({ + title, + thumbnailSource, + thumbnailAlt, + content, + anchorElement, + onClose, + placement, + closeWithBackdropClick, +}: Props) => { + const classes = useClasses(); + const gdevelopTheme = React.useContext(GDevelopThemeContext); + const { currentlyRunningInAppTutorial } = React.useContext( + InAppTutorialContext + ); + if (currentlyRunningInAppTutorial) return null; + + const popper = ( + + {({ TransitionProps }) => ( + + + + + + {title} + + + + + + {thumbnailSource && thumbnailAlt && ( + + )} + {content} + + + + + )} + + ); + + if (closeWithBackdropClick) { + return ( + { + event.preventDefault(); + event.stopPropagation(); + onClose(); + }} + > + {popper} + + ); + } + return popper; +}; + +export default HighlightingTooltip; diff --git a/newIDE/app/src/Utils/Analytics/EventSender.js b/newIDE/app/src/Utils/Analytics/EventSender.js index 905ab675487b..97f17cbadcdb 100644 --- a/newIDE/app/src/Utils/Analytics/EventSender.js +++ b/newIDE/app/src/Utils/Analytics/EventSender.js @@ -192,6 +192,12 @@ export const sendExportLaunched = (exportKind: string) => { }); }; +export const sendGameDetailsOpened = (options: { + from: 'profile' | 'homepage' | 'projectManager', +}) => { + recordEvent('game_details_opened', options); +}; + export const sendExampleDetailsOpened = (slug: string) => { recordEvent('example-details-opened', { slug }); }; diff --git a/newIDE/app/src/Utils/UseDisplayNewFeature.js b/newIDE/app/src/Utils/UseDisplayNewFeature.js new file mode 100644 index 000000000000..f4eb8fb300c8 --- /dev/null +++ b/newIDE/app/src/Utils/UseDisplayNewFeature.js @@ -0,0 +1,69 @@ +// @flow + +import * as React from 'react'; +import PreferencesContext from '../MainFrame/Preferences/PreferencesContext'; + +const featuresDisplaySettings = { + gamesDashboardInProjectManager: { count: 2, intervalInDays: 7 }, + gamesDashboardInHomePage: { count: 2, intervalInDays: 7 }, +}; + +const ONE_DAY = 24 * 3600 * 1000; + +type Feature = string; + +const useDisplayNewFeature = () => { + const { + values: { newFeaturesAcknowledgements }, + setNewFeaturesAcknowledgements, + } = React.useContext(PreferencesContext); + + const shouldDisplayNewFeatureHighlighting = React.useCallback( + ({ featureId }: { featureId: Feature }): boolean => { + const settings = featuresDisplaySettings[featureId]; + if (!settings) return false; + + const acknowledgments = newFeaturesAcknowledgements[featureId]; + if (!acknowledgments) return true; + + const { count, intervalInDays } = settings; + const { dates } = acknowledgments; + if (dates.length >= count) return false; + + const lastDate = dates[dates.length - 1]; + + return Date.now() > lastDate + intervalInDays * ONE_DAY; + }, + [newFeaturesAcknowledgements] + ); + + const acknowledgeNewFeature = React.useCallback( + ({ featureId }: { featureId: Feature }) => { + if (!featuresDisplaySettings[featureId]) return; + + const acknowledgments = newFeaturesAcknowledgements[featureId]; + if (!acknowledgments) { + setNewFeaturesAcknowledgements({ + ...newFeaturesAcknowledgements, + [featureId]: { dates: [Date.now()] }, + }); + return; + } + setNewFeaturesAcknowledgements({ + ...newFeaturesAcknowledgements, + [featureId]: { + ...acknowledgments, + dates: [...acknowledgments.dates, Date.now()], + }, + }); + }, + [newFeaturesAcknowledgements, setNewFeaturesAcknowledgements] + ); + + return { + shouldDisplayNewFeatureHighlighting, + acknowledgeNewFeature, + }; +}; + +export default useDisplayNewFeature; diff --git a/newIDE/app/src/Utils/UseOpenInitialDialog.js b/newIDE/app/src/Utils/UseOpenInitialDialog.js index a5d2590ae8a1..d4a8d98706b7 100644 --- a/newIDE/app/src/Utils/UseOpenInitialDialog.js +++ b/newIDE/app/src/Utils/UseOpenInitialDialog.js @@ -59,9 +59,8 @@ const useOpenInitialDialog = ({ removeRouteArguments(['initial-dialog', 'tutorial-id']); break; case 'games-dashboard': - openProfileDialog(true); - // As the games dashboard is not a dialog in itself, we don't remove the argument - // and let the ProfileDialog do it once the tab is opened. + // Do nothing as it should open the games dashboard on the homepage + // in the manage tab. So the homepage handles the route arguments itself. break; default: break; diff --git a/newIDE/app/src/stories/componentStories/GameDashboard/GamesList.stories.js b/newIDE/app/src/stories/componentStories/GameDashboard/GamesList.stories.js index aa6d8a4f2c30..e2ddb2f46b7d 100644 --- a/newIDE/app/src/stories/componentStories/GameDashboard/GamesList.stories.js +++ b/newIDE/app/src/stories/componentStories/GameDashboard/GamesList.stories.js @@ -3,18 +3,16 @@ import * as React from 'react'; import muiDecorator from '../../ThemeDecorator'; - import paperDecorator from '../../PaperDecorator'; + +import { action } from '@storybook/addon-actions'; import { fakeSilverAuthenticatedUser, game1, game2, } from '../../../fixtures/GDevelopServicesTestData'; -import MockAdapter from 'axios-mock-adapter'; -import axios from 'axios'; -import { GDevelopGameApi } from '../../../Utils/GDevelopServices/ApiConfigs'; import AuthenticatedUserContext from '../../../Profile/AuthenticatedUserContext'; -import { GamesList } from '../../../GameDashboard/GamesList'; +import GamesList from '../../../GameDashboard/GamesList'; export default { title: 'GameDashboard/GamesList', @@ -23,54 +21,15 @@ export default { }; export const WithoutAProjectOpened = () => { - const mock = new MockAdapter(axios); - mock - .onGet(`${GDevelopGameApi.baseUrl}/game`) - .reply(200, [game1, game2]) - .onAny() - .reply(config => { - console.error(`Unexpected call to ${config.url} (${config.method})`); - return [504, null]; - }); - - return ( - - - - ); -}; - -export const WithoutAProjectOpenedLongLoading = () => { - const mock = new MockAdapter(axios, { delayResponse: 2500 }); - mock - .onGet(`${GDevelopGameApi.baseUrl}/game`) - .reply(200, [game1, game2]) - .onAny() - .reply(config => { - console.error(`Unexpected call to ${config.url} (${config.method})`); - return [504, null]; - }); - - return ( - - - - ); -}; - -export const WithAnError = () => { - const mock = new MockAdapter(axios); - mock - .onGet(`${GDevelopGameApi.baseUrl}/game`) - .reply(500) - .onAny() - .reply(config => { - console.error(`Unexpected call to ${config.url} (${config.method})`); - return [504, null]; - }); return ( - + ); }; diff --git a/newIDE/app/src/stories/componentStories/ProjectManager/ProjectManager.stories.js b/newIDE/app/src/stories/componentStories/ProjectManager/ProjectManager.stories.js index 455f8bce7aa5..0c90489068a4 100644 --- a/newIDE/app/src/stories/componentStories/ProjectManager/ProjectManager.stories.js +++ b/newIDE/app/src/stories/componentStories/ProjectManager/ProjectManager.stories.js @@ -9,7 +9,6 @@ import GDevelopJsInitializerDecorator, { testProject, } from '../../GDevelopJsInitializerDecorator'; import fakeHotReloadPreviewButtonProps from '../../FakeHotReloadPreviewButtonProps'; -import defaultShortcuts from '../../../KeyboardShortcuts/DefaultShortcuts'; export default { title: 'Project Creation/ProjectManager', @@ -19,7 +18,6 @@ export default { export const Default = () => ( true} onChangeProjectName={action('onChangeProjectName')} onOpenExternalEvents={action('onOpenExternalEvents')} @@ -45,6 +43,7 @@ export const Default = () => ( onReloadEventsFunctionsExtensions={action( 'onReloadEventsFunctionsExtensions' )} + onShareProject={action('onShareProject')} freezeUpdate={false} hotReloadPreviewButtonProps={fakeHotReloadPreviewButtonProps} resourceManagementProps={fakeResourceManagementProps} @@ -54,7 +53,6 @@ export const Default = () => ( export const ErrorsInFunctions = () => ( true} onChangeProjectName={action('onChangeProjectName')} onOpenExternalEvents={action('onOpenExternalEvents')} @@ -82,6 +80,7 @@ export const ErrorsInFunctions = () => ( onReloadEventsFunctionsExtensions={action( 'onReloadEventsFunctionsExtensions' )} + onShareProject={action('onShareProject')} freezeUpdate={false} hotReloadPreviewButtonProps={fakeHotReloadPreviewButtonProps} resourceManagementProps={fakeResourceManagementProps} diff --git a/newIDE/app/src/stories/componentStories/UI/HighlightingTooltip.stories.js b/newIDE/app/src/stories/componentStories/UI/HighlightingTooltip.stories.js new file mode 100644 index 000000000000..88d394efd0ff --- /dev/null +++ b/newIDE/app/src/stories/componentStories/UI/HighlightingTooltip.stories.js @@ -0,0 +1,112 @@ +// @flow +import * as React from 'react'; +import { action } from '@storybook/addon-actions'; + +import muiDecorator from '../../ThemeDecorator'; +import paperDecorator from '../../PaperDecorator'; + +import HighlightingTooltip from '../../../UI/HighlightingTooltip'; +import FixedHeightFlexContainer from '../../FixedHeightFlexContainer'; +import Text from '../../../UI/Text'; +import Link from '../../../UI/Link'; +import Window from '../../../Utils/Window'; +import TreeLeaves from '../../../UI/CustomSvgIcons/TreeLeaves'; + +export default { + title: 'UI Building Blocks/HighlightingTooltip', + component: HighlightingTooltip, + decorators: [paperDecorator, muiDecorator], +}; + +export const WithThumbnailSetByHref = () => { + const [anchorEl, setAnchorEl] = React.useState(null); + return ( + +
setAnchorEl(ref)} + > + Anchor +
+ {anchorEl && ( + + Follow your game’s online performance, manage published versions, + and collect player feedback. + , + + Window.openExternalURL('https://gdevelop.io')} + > + Learn more + + , + ]} + thumbnailSource="https://resources.gdevelop-app.com/tutorials/images/best-practices-when-making-games.png?gdUsage=img" + onClose={action('onClose')} + closeWithBackdropClick={false} + /> + )} +
+ ); +}; + +export const WithThumbnailSetInContent = () => { + const [anchorEl, setAnchorEl] = React.useState(null); + return ( + +
setAnchorEl(ref)} + > + Anchor +
+ {anchorEl && ( + , + + + Follow your game’s online performance, manage published versions, + and collect player feedback. + , + + Window.openExternalURL('https://gdevelop.io')} + > + Learn more + + , + ]} + onClose={action('onClose')} + closeWithBackdropClick={false} + /> + )} +
+ ); +};