diff --git a/newIDE/app/src/AssetStore/ExtensionStore/ExtensionDetailPanel.js b/newIDE/app/src/AssetStore/ExtensionStore/ExtensionDetailPanel.js new file mode 100644 index 000000000000..38df6616b672 --- /dev/null +++ b/newIDE/app/src/AssetStore/ExtensionStore/ExtensionDetailPanel.js @@ -0,0 +1,417 @@ +// @flow +import { t, Trans } from '@lingui/macro'; +import React from 'react'; +import FlatButton from '../../UI/FlatButton'; +import RaisedButton from '../../UI/RaisedButton'; +import { + type ExtensionShortHeader, + type ExtensionHeader, + type BehaviorShortHeader, + type ObjectShortHeader, + getExtensionHeader, +} from '../../Utils/GDevelopServices/Extension'; +import { + getBreakingChanges, + formatOldBreakingChanges, + formatBreakingChanges, + isCompatibleWithGDevelopVersion, +} from '../../Utils/Extension/ExtensionCompatibilityChecker.js'; +import LeftLoader from '../../UI/LeftLoader'; +import PlaceholderLoader from '../../UI/PlaceholderLoader'; +import PlaceholderError from '../../UI/PlaceholderError'; +import { MarkdownText } from '../../UI/MarkdownText'; +import Text from '../../UI/Text'; +import AlertMessage from '../../UI/AlertMessage'; +import { getIDEVersion } from '../../Version'; +import { Column, Line } from '../../UI/Grid'; +import { Divider } from '@material-ui/core'; +import { ColumnStackLayout, LineStackLayout } from '../../UI/Layout'; +import { IconContainer } from '../../UI/IconContainer'; +import { UserPublicProfileChip } from '../../UI/User/UserPublicProfileChip'; +import Window from '../../Utils/Window'; +import { useExtensionUpdate, type UpdateMetadata } from './UseExtensionUpdates'; +import HelpButton from '../../UI/HelpButton'; +import useAlertDialog from '../../UI/Alert/useAlertDialog'; +import { Accordion, AccordionHeader, AccordionBody } from '../../UI/Accordion'; + +export const useOutOfDateAlertDialog = (): (() => Promise) => { + const { showConfirmation } = useAlertDialog(); + return async (): Promise => { + return await showConfirmation({ + title: t`Outdated extension`, + message: t`The extension installed in this project is not up to date. + Consider upgrading it before reporting any issue.`, + confirmButtonLabel: t`Close`, + dismissButtonLabel: t`Report anyway`, + }); + }; +}; + +const getTransformedDescription = (extensionHeader: ExtensionHeader) => { + if ( + extensionHeader.description.substr( + 0, + extensionHeader.shortDescription.length + ) === extensionHeader.shortDescription + ) { + return extensionHeader.description.substr( + extensionHeader.shortDescription.length + ); + } + + return extensionHeader.description; +}; + +type ExtensionDetail = { + isAlreadyInstalled: boolean, + isCompatible: boolean, + canInstallExtension: boolean, + installedExtension: gdEventsFunctionsExtension | null, + extensionUpdate: UpdateMetadata | null, + extensionHeader: ExtensionHeader | null, + error: Error | null, + onInstallExtension: () => void, + loadExtensionheader: () => void, + onUserReportIssue: () => Promise, + renderInstallButtonLabel: () => React.Node, +}; + +export const useExtensionDetail = ({ + extensionShortHeader, + isInstalling, + onInstall, + project, +}: { + extensionShortHeader: + | ExtensionShortHeader + | BehaviorShortHeader + | ObjectShortHeader, + isInstalling: boolean, + onInstall?: () => Promise, + project: gdProject, +}): ExtensionDetail => { + const isAlreadyInstalled: boolean = project.hasEventsFunctionsExtensionNamed( + extensionShortHeader.name + ); + + const installedExtension = isAlreadyInstalled + ? project.getEventsFunctionsExtension(extensionShortHeader.name) + : null; + + const isFromStore = installedExtension + ? installedExtension.getOriginName() === 'gdevelop-extension-store' + : false; + + const extensionUpdate = useExtensionUpdate(project, extensionShortHeader); + + const [error, setError] = React.useState(null); + const [ + extensionHeader, + setExtensionHeader, + ] = React.useState(null); + + const loadExtensionheader = React.useCallback( + () => { + setError(null); + getExtensionHeader(extensionShortHeader).then( + extensionHeader => { + setExtensionHeader(extensionHeader); + }, + error => { + setError(error); + } + ); + }, + [extensionShortHeader] + ); + + React.useEffect(() => loadExtensionheader(), [loadExtensionheader]); + + const isCompatible = isCompatibleWithGDevelopVersion( + getIDEVersion(), + extensionShortHeader.gdevelopVersion + ); + + const canInstallExtension = !isInstalling && isCompatible; + const onInstallExtension = React.useCallback( + () => { + if (canInstallExtension && onInstall) { + if (isAlreadyInstalled) { + let dialogText = + 'This extension is already in your project, this will install the latest version. You may have to do some adaptations to make sure your game still works. Do you want to continue?'; + if (!isFromStore) + dialogText = + 'An other extension with the same name is already in your project. Installing this extension will overwrite your current extension. Do you want to continue?'; + + const answer = Window.showConfirmDialog(dialogText); + if (!answer) return; + onInstall(); + } else { + onInstall(); + } + } + }, + [onInstall, canInstallExtension, isAlreadyInstalled, isFromStore] + ); + + const showOutOfDateAlert = useOutOfDateAlertDialog(); + const onUserReportIssue = React.useCallback( + async () => { + if (extensionUpdate) { + const shouldNotReportIssue = await showOutOfDateAlert(); + if (shouldNotReportIssue) { + return; + } + } + Window.openExternalURL( + `https://github.com/GDevelopApp/GDevelop-extensions/issues/new` + + `?assignees=&labels=&template=bug-report.yml&title=[${ + extensionShortHeader.name + }] Issue short description` + ); + }, + [extensionShortHeader.name, extensionUpdate, showOutOfDateAlert] + ); + + const renderInstallButtonLabel = React.useCallback( + (): React.Node => + !isCompatible ? ( + Not compatible + ) : isAlreadyInstalled ? ( + isFromStore ? ( + extensionUpdate && + installedExtension && + extensionShortHeader.version !== installedExtension.getVersion() ? ( + extensionShortHeader.tier === 'experimental' ? ( + Update (could break the project) + ) : ( + Update + ) + ) : ( + Re-install + ) + ) : ( + Replace existing extension + ) + ) : ( + Install in project + ), + [ + extensionShortHeader.tier, + extensionShortHeader.version, + extensionUpdate, + installedExtension, + isAlreadyInstalled, + isCompatible, + isFromStore, + ] + ); + + return React.useMemo( + () => ({ + isAlreadyInstalled, + isCompatible, + canInstallExtension, + installedExtension, + extensionUpdate, + extensionHeader, + error, + onInstallExtension, + loadExtensionheader, + onUserReportIssue, + renderInstallButtonLabel, + }), + [ + isAlreadyInstalled, + isCompatible, + canInstallExtension, + installedExtension, + extensionUpdate, + extensionHeader, + error, + onInstallExtension, + loadExtensionheader, + onUserReportIssue, + renderInstallButtonLabel, + ] + ); +}; + +type Props = {| + extensionShortHeader: + | ExtensionShortHeader + | BehaviorShortHeader + | ObjectShortHeader, + isInstalling: boolean, + onInstall?: () => Promise, + extensionDetail: ExtensionDetail, + shouldDisplayButtons: boolean, +|}; + +const ExtensionDetailPanel = ({ + extensionShortHeader, + extensionDetail, + isInstalling, + onInstall, + shouldDisplayButtons, +}: Props): React.Node => { + const { + isAlreadyInstalled, + isCompatible, + canInstallExtension, + installedExtension, + extensionUpdate, + extensionHeader, + error, + onInstallExtension, + loadExtensionheader, + onUserReportIssue, + renderInstallButtonLabel, + } = extensionDetail; + + const newBreakingChangesText = installedExtension + ? formatBreakingChanges( + getBreakingChanges( + installedExtension.getVersion(), + extensionShortHeader + ) + ) + : null; + const oldBreakingChangesText = installedExtension + ? formatOldBreakingChanges( + installedExtension.getVersion(), + extensionShortHeader + ) + : null; + + return ( + + + + + + {extensionUpdate && + installedExtension && + extensionShortHeader.version !== installedExtension.getVersion() ? ( + {`Version ${installedExtension.getVersion()} (${ + extensionShortHeader.version + } available)`} + ) : ( + {`Version ${extensionShortHeader.version}`} + )} + + +
+ {extensionShortHeader.authors && + extensionShortHeader.authors.map(author => ( + + ))} +
+
+
+ {shouldDisplayButtons && onInstall && ( + + + + + + )} +
+ + {extensionHeader + ? extensionHeader.shortDescription + : typeof extensionShortHeader.shortDescription === 'string' + ? extensionShortHeader.shortDescription || '' + : ''} + + + {extensionHeader && ( + + )} + {extensionShortHeader.tier === 'experimental' && ( + + + This is an extension made by a community member and it only got + through a light review by the GDevelop extension team. As such, we + can't guarantee it meets all the quality standards of fully reviewed + extensions. + + + )} + {!isCompatible && ( + + + Unfortunately, this extension requires a newer version of GDevelop + to work. Update GDevelop to be able to use this extension in your + project. + + + )} + {!extensionHeader && !error && } + {!extensionHeader && error && ( + + + Can't load the extension registry. Verify your internet connection + or try again later. + + + )} + {newBreakingChangesText && ( + <> + + Breaking changes + + + + )} + {oldBreakingChangesText && ( + + + + Previous breaking changes (no longer relevant) + + + + + + + )} + + {shouldDisplayButtons && + extensionHeader && + extensionHeader.helpPath && ( + + )} + {shouldDisplayButtons && isAlreadyInstalled && ( + Report an issue} + onClick={() => onUserReportIssue()} + /> + )} + +
+ ); +}; + +export default ExtensionDetailPanel; diff --git a/newIDE/app/src/AssetStore/ExtensionStore/ExtensionInstallDialog.js b/newIDE/app/src/AssetStore/ExtensionStore/ExtensionInstallDialog.js index 70d26795f8ce..72ac6420f47b 100644 --- a/newIDE/app/src/AssetStore/ExtensionStore/ExtensionInstallDialog.js +++ b/newIDE/app/src/AssetStore/ExtensionStore/ExtensionInstallDialog.js @@ -5,34 +5,15 @@ import Dialog, { DialogPrimaryButton } from '../../UI/Dialog'; import FlatButton from '../../UI/FlatButton'; import { type ExtensionShortHeader, - type ExtensionHeader, type BehaviorShortHeader, type ObjectShortHeader, - getExtensionHeader, } from '../../Utils/GDevelopServices/Extension'; -import { - getBreakingChanges, - formatOldBreakingChanges, - formatBreakingChanges, - isCompatibleWithGDevelopVersion, -} from '../../Utils/Extension/ExtensionCompatibilityChecker.js'; import LeftLoader from '../../UI/LeftLoader'; -import PlaceholderLoader from '../../UI/PlaceholderLoader'; -import PlaceholderError from '../../UI/PlaceholderError'; -import { MarkdownText } from '../../UI/MarkdownText'; -import Text from '../../UI/Text'; -import AlertMessage from '../../UI/AlertMessage'; -import { getIDEVersion } from '../../Version'; -import { Column, Line } from '../../UI/Grid'; -import { Divider } from '@material-ui/core'; -import { ColumnStackLayout } from '../../UI/Layout'; -import { IconContainer } from '../../UI/IconContainer'; -import { UserPublicProfileChip } from '../../UI/User/UserPublicProfileChip'; -import Window from '../../Utils/Window'; -import { useExtensionUpdate } from './UseExtensionUpdates'; import HelpButton from '../../UI/HelpButton'; import useAlertDialog from '../../UI/Alert/useAlertDialog'; -import { Accordion, AccordionHeader, AccordionBody } from '../../UI/Accordion'; +import ExtensionDetailPanel, { + useExtensionDetail, +} from './ExtensionDetailPanel'; export const useOutOfDateAlertDialog = (): (() => Promise) => { const { showConfirmation } = useAlertDialog(); @@ -47,21 +28,6 @@ export const useOutOfDateAlertDialog = (): (() => Promise) => { }; }; -const getTransformedDescription = (extensionHeader: ExtensionHeader) => { - if ( - extensionHeader.description.substr( - 0, - extensionHeader.shortDescription.length - ) === extensionHeader.shortDescription - ) { - return extensionHeader.description.substr( - extensionHeader.shortDescription.length - ); - } - - return extensionHeader.description; -}; - type Props = {| extensionShortHeader: | ExtensionShortHeader @@ -82,103 +48,20 @@ const ExtensionInstallDialog = ({ onEdit, project, }: Props): React.Node => { - const isAlreadyInstalled: boolean = project.hasEventsFunctionsExtensionNamed( - extensionShortHeader.name - ); - - const installedExtension = isAlreadyInstalled - ? project.getEventsFunctionsExtension(extensionShortHeader.name) - : null; - - const isFromStore = installedExtension - ? installedExtension.getOriginName() === 'gdevelop-extension-store' - : false; - - const newBreakingChangesText = installedExtension - ? formatBreakingChanges( - getBreakingChanges( - installedExtension.getVersion(), - extensionShortHeader - ) - ) - : null; - const oldBreakingChangesText = installedExtension - ? formatOldBreakingChanges( - installedExtension.getVersion(), - extensionShortHeader - ) - : null; - - const extensionUpdate = useExtensionUpdate(project, extensionShortHeader); - - const [error, setError] = React.useState(null); - const [ + const extensionDetail = useExtensionDetail({ + extensionShortHeader, + isInstalling, + onInstall, + project, + }); + const { + isAlreadyInstalled, + canInstallExtension, extensionHeader, - setExtensionHeader, - ] = React.useState(null); - - const loadExtensionheader = React.useCallback( - () => { - setError(null); - getExtensionHeader(extensionShortHeader).then( - extensionHeader => { - setExtensionHeader(extensionHeader); - }, - error => { - setError(error); - } - ); - }, - [extensionShortHeader] - ); - - React.useEffect(() => loadExtensionheader(), [loadExtensionheader]); - - const isCompatible = isCompatibleWithGDevelopVersion( - getIDEVersion(), - extensionShortHeader.gdevelopVersion - ); - - const canInstallExtension = !isInstalling && isCompatible; - const onInstallExtension = React.useCallback( - () => { - if (canInstallExtension && onInstall) { - if (isAlreadyInstalled) { - let dialogText = - 'This extension is already in your project, this will install the latest version. You may have to do some adaptations to make sure your game still works. Do you want to continue?'; - if (!isFromStore) - dialogText = - 'An other extension with the same name is already in your project. Installing this extension will overwrite your current extension. Do you want to continue?'; - - const answer = Window.showConfirmDialog(dialogText); - if (!answer) return; - onInstall(); - } else { - onInstall(); - } - } - }, - [onInstall, canInstallExtension, isAlreadyInstalled, isFromStore] - ); - - const showOutOfDateAlert = useOutOfDateAlertDialog(); - const onUserReportIssue = React.useCallback( - async () => { - if (extensionUpdate) { - const shouldNotReportIssue = await showOutOfDateAlert(); - if (shouldNotReportIssue) { - return; - } - } - Window.openExternalURL( - `https://github.com/GDevelopApp/GDevelop-extensions/issues/new` + - `?assignees=&labels=&template=bug-report.yml&title=[${ - extensionShortHeader.name - }] Issue short description` - ); - }, - [extensionShortHeader.name, extensionUpdate, showOutOfDateAlert] - ); + onInstallExtension, + onUserReportIssue, + renderInstallButtonLabel, + } = extensionDetail; return ( Not compatible - ) : isAlreadyInstalled ? ( - isFromStore ? ( - extensionUpdate && - installedExtension && - extensionShortHeader.version !== - installedExtension.getVersion() ? ( - extensionShortHeader.tier === 'experimental' ? ( - Update (could break the project) - ) : ( - Update - ) - ) : ( - Re-install - ) - ) : ( - Replace existing extension - ) - ) : ( - Install in project - ) - } + label={renderInstallButtonLabel()} primary onClick={onInstallExtension} disabled={!canInstallExtension} @@ -261,103 +121,13 @@ const ExtensionInstallDialog = ({ onRequestClose={onClose} onApply={onInstall ? onInstallExtension : onClose} > - - - - - - {extensionUpdate && - installedExtension && - extensionShortHeader.version !== - installedExtension.getVersion() ? ( - {`Version ${installedExtension.getVersion()} (${ - extensionShortHeader.version - } available)`} - ) : ( - {`Version ${extensionShortHeader.version}`} - )} - - -
- {extensionShortHeader.authors && - extensionShortHeader.authors.map(author => ( - - ))} -
-
-
-
- - {extensionHeader - ? extensionHeader.shortDescription - : typeof extensionShortHeader.shortDescription === 'string' - ? extensionShortHeader.shortDescription || '' - : ''} - - - {extensionHeader && ( - - )} - {extensionShortHeader.tier === 'experimental' && ( - - - This is an extension made by a community member and it only got - through a light review by the GDevelop extension team. As such, we - can't guarantee it meets all the quality standards of fully - reviewed extensions. - - - )} - {!isCompatible && ( - - - Unfortunately, this extension requires a newer version of GDevelop - to work. Update GDevelop to be able to use this extension in your - project. - - - )} - {!extensionHeader && !error && } - {!extensionHeader && error && ( - - - Can't load the extension registry. Verify your internet connection - or try again later. - - - )} - {newBreakingChangesText && ( - <> - - Breaking changes - - - - )} - {oldBreakingChangesText && ( - - - - Previous breaking changes (no longer relevant) - - - - - - - )} -
+
); }; diff --git a/newIDE/app/src/AssetStore/ExtensionStore/UseExtensionUpdates.js b/newIDE/app/src/AssetStore/ExtensionStore/UseExtensionUpdates.js index 12b9ca8369bb..9b6854abf062 100644 --- a/newIDE/app/src/AssetStore/ExtensionStore/UseExtensionUpdates.js +++ b/newIDE/app/src/AssetStore/ExtensionStore/UseExtensionUpdates.js @@ -8,7 +8,7 @@ import type { } from '../../Utils/GDevelopServices/Extension'; type UpdateType = 'patch' | 'minor' | 'major'; -type UpdateMetadata = {| +export type UpdateMetadata = {| type: UpdateType, currentVersion: string, newestVersion: string, diff --git a/newIDE/app/src/AssetStore/ExtensionStore/index.js b/newIDE/app/src/AssetStore/ExtensionStore/index.js index 3e10e2fd9919..68be4ee90029 100644 --- a/newIDE/app/src/AssetStore/ExtensionStore/index.js +++ b/newIDE/app/src/AssetStore/ExtensionStore/index.js @@ -1,4 +1,5 @@ // @flow +import { t, Trans } from '@lingui/macro'; import { type I18n as I18nType } from '@lingui/core'; import * as React from 'react'; import SearchBar from '../../UI/SearchBar'; @@ -13,8 +14,7 @@ import { sendExtensionAddedToProject, } from '../../Utils/Analytics/EventSender'; import useDismissableTutorialMessage from '../../Hints/useDismissableTutorialMessage'; -import { t } from '@lingui/macro'; -import { ColumnStackLayout } from '../../UI/Layout'; +import { ColumnStackLayout, LineStackLayout } from '../../UI/Layout'; import { Column, Line } from '../../UI/Grid'; import PreferencesContext from '../../MainFrame/Preferences/PreferencesContext'; import { ResponsiveLineStackLayout } from '../../UI/Layout'; @@ -23,6 +23,41 @@ import SelectOption from '../../UI/SelectOption'; import ElementWithMenu from '../../UI/Menu/ElementWithMenu'; import IconButton from '../../UI/IconButton'; import ThreeDotsMenu from '../../UI/CustomSvgIcons/ThreeDotsMenu'; +import ExtensionDetailPanel, { + useExtensionDetail, +} from './ExtensionDetailPanel'; +import { useResponsiveWindowSize } from '../../UI/Responsive/ResponsiveWindowMeasurer'; +import Text from '../../UI/Text'; +import { Divider } from '@material-ui/core'; + +export const ExtensionDetailSidePanel = ({ + extensionShortHeader, + isInstalling, + onInstall, + project, +}: { + extensionShortHeader: ExtensionShortHeader, + isInstalling: boolean, + onInstall?: () => Promise, + project: gdProject, +}): React.Node => { + const extensionDetail = useExtensionDetail({ + extensionShortHeader, + isInstalling, + onInstall, + project, + }); + + return ( + + ); +}; type Props = {| isInstalling: boolean, @@ -55,6 +90,7 @@ export const ExtensionStore = ({ chosenCategory, setChosenCategory, } = React.useContext(ExtensionStoreContext); + const { isMobile } = useResponsiveWindowSize(); React.useEffect( () => { @@ -87,86 +123,120 @@ export const ExtensionStore = ({ return ( - - - - { - setChosenCategory(value); - }} - > - - {allCategories.map(category => ( - - ))} - - - - {}} - placeholder={t`Search extensions`} - autoFocus="desktop" - /> - - - - - } - buildMenuTemplate={(i18n: I18nType) => [ - { - label: preferences.values.showExperimentalExtensions - ? i18n._(t`Hide experimental extensions`) - : i18n._(t`Show experimental extensions`), - click: () => { - preferences.setShowExperimentalExtensions( - !preferences.values.showExperimentalExtensions - ); + + + + + { + setChosenCategory(value); + }} + > + + {allCategories.map(category => ( + + ))} + + + + {}} + placeholder={t`Search extensions`} + autoFocus="desktop" + /> + + + + + } + buildMenuTemplate={(i18n: I18nType) => [ + { + label: preferences.values.showExperimentalExtensions + ? i18n._(t`Hide experimental extensions`) + : i18n._(t`Show experimental extensions`), + click: () => { + preferences.setShowExperimentalExtensions( + !preferences.values.showExperimentalExtensions + ); + }, }, - }, - ]} + ]} + /> + + + {DismissableTutorialMessage} + + item) + } + getSearchItemUniqueId={getExtensionName} + // $FlowFixMe[missing-local-annot] + renderSearchItem={(extensionShortHeader, onHeightComputed) => ( + { + sendExtensionDetailsOpened(extensionShortHeader.name); + setSelectedExtensionShortHeader(extensionShortHeader); + }} /> - - - {DismissableTutorialMessage} + )} + /> - item) - } - getSearchItemUniqueId={getExtensionName} - // $FlowFixMe[missing-local-annot] - renderSearchItem={(extensionShortHeader, onHeightComputed) => ( - { - sendExtensionDetailsOpened(extensionShortHeader.name); - setSelectedExtensionShortHeader(extensionShortHeader); - }} - /> - )} - /> - - {!!selectedExtensionShortHeader && ( + {!isMobile ? ( + + + {selectedExtensionShortHeader ? ( + + { + sendExtensionAddedToProject( + selectedExtensionShortHeader.name + ); + await onInstall(selectedExtensionShortHeader); + }} + /> + + ) : ( + + + Select an extension + + + )} + + ) : null} + + {isMobile && !!selectedExtensionShortHeader && (