diff --git a/newIDE/app/src/BehaviorsEditor/EnumerateBehaviorsMetadata.js b/newIDE/app/src/BehaviorsEditor/EnumerateBehaviorsMetadata.js index f792c2a3e7..e125ad6386 100644 --- a/newIDE/app/src/BehaviorsEditor/EnumerateBehaviorsMetadata.js +++ b/newIDE/app/src/BehaviorsEditor/EnumerateBehaviorsMetadata.js @@ -43,3 +43,23 @@ export const enumerateBehaviorsMetadata = ( }) ); }; + +export const filterEnumeratedBehaviorMetadata = ( + list: Array, + searchText: string +): Array => { + if (!searchText) return list; + + const lowercaseSearchText = searchText.toLowerCase(); + + return list.filter(enumerateBehaviorsMetadata => { + return ( + enumerateBehaviorsMetadata.fullName + .toLowerCase() + .indexOf(lowercaseSearchText) !== -1 || + enumerateBehaviorsMetadata.description + .toLowerCase() + .indexOf(lowercaseSearchText) !== -1 + ); + }); +}; diff --git a/newIDE/app/src/BehaviorsEditor/NewBehaviorDialog.js b/newIDE/app/src/BehaviorsEditor/NewBehaviorDialog.js index c62b1540cb..d3fd79d1d9 100644 --- a/newIDE/app/src/BehaviorsEditor/NewBehaviorDialog.js +++ b/newIDE/app/src/BehaviorsEditor/NewBehaviorDialog.js @@ -9,6 +9,7 @@ import HelpButton from '../UI/HelpButton'; import FlatButton from 'material-ui/FlatButton'; import Subheader from 'material-ui/Subheader'; import Avatar from 'material-ui/Avatar'; +import { Tabs, Tab } from 'material-ui/Tabs'; import { List, ListItem } from 'material-ui/List'; import Visibility from 'material-ui/svg-icons/action/visibility'; import VisibilityOff from 'material-ui/svg-icons/action/visibility-off'; @@ -18,7 +19,11 @@ import { getDeprecatedBehaviorsInformation } from '../Hints'; import { type EnumeratedBehaviorMetadata, enumerateBehaviorsMetadata, + filterEnumeratedBehaviorMetadata, } from './EnumerateBehaviorsMetadata'; +import SearchBar from 'material-ui-search-bar'; +import EmptyMessage from '../UI/EmptyMessage'; +import ExtensionsSearch from '../ExtensionsSearch'; const styles = { icon: { borderRadius: 0 }, @@ -58,10 +63,22 @@ type Props = {| type State = {| behaviorMetadata: Array, showDeprecated: boolean, + searchText: string, |}; export default class NewBehaviorDialog extends Component { - state = { ...this._loadFrom(this.props.project), showDeprecated: false }; + state = { + ...this._loadFrom(this.props.project), + showDeprecated: false, + searchText: '', + }; + _searchBar = React.createRef(); + + componentDidMount() { + setTimeout(() => { + if (this._searchBar.current) this._searchBar.current.focus(); + }, 20 /* Be sure that the search bar is shown */); + } _loadFrom( project: gdProject @@ -90,26 +107,29 @@ export default class NewBehaviorDialog extends Component { }); }; + _onNewExtensionInstalled = () => { + // Reload behaviors + this.setState(this._loadFrom(this.props.project)); + + // TODO: Go back to the first tab. + // TODO: Display snackbar? + }; + render() { const { project, open, onClose, objectType } = this.props; - const { showDeprecated, behaviorMetadata } = this.state; + const { showDeprecated, behaviorMetadata, searchText } = this.state; if (!open || !project) return null; - const actions = [ - Close} - primary={false} - onClick={onClose} - />, - ]; - const deprecatedBehaviorsInformation = getDeprecatedBehaviorsInformation(); - const behaviors = behaviorMetadata.filter( + const filteredBehaviorMetadata = filterEnumeratedBehaviorMetadata( + behaviorMetadata, + searchText + ); + const behaviors = filteredBehaviorMetadata.filter( ({ type }) => !deprecatedBehaviorsInformation[type] ); - const deprecatedBehaviors = behaviorMetadata.filter( + const deprecatedBehaviors = filteredBehaviorMetadata.filter( ({ type }) => !!deprecatedBehaviorsInformation[type] ); @@ -134,60 +154,106 @@ export default class NewBehaviorDialog extends Component { ); }; + const hasSearchNoResult = + !!searchText && !behaviors.length && !deprecatedBehaviors.length; + return ( {({ i18n }) => ( Add a new behavior to the object} - actions={actions} + actions={[ + Close} + primary={false} + onClick={onClose} + />, + ]} secondaryActions={} open={open} noMargin autoScrollBodyContent > - - {behaviors.map((behaviorMetadata, index) => ( - chooseBehavior(i18n, behaviorMetadata)} - disabled={!canBehaviorBeUsed(behaviorMetadata)} + + Installed Behaviors} value="installed"> + { + if (behaviors.length) { + chooseBehavior(i18n, behaviors[0]); + } else if (showDeprecated && deprecatedBehaviors.length) { + chooseBehavior(i18n, deprecatedBehaviors[0]); + } + }} + onChange={text => + this.setState({ + searchText: text, + }) + } + ref={this._searchBar} /> - ))} - {showDeprecated && ( - - Deprecated (old, prefer not to use anymore) - - )} - {showDeprecated && - deprecatedBehaviors.map((behaviorMetadata, index) => ( - chooseBehavior(i18n, behaviorMetadata)} - disabled={!canBehaviorBeUsed(behaviorMetadata)} - /> - ))} - - - {!showDeprecated ? ( - } - primary={false} - onClick={() => this._showDeprecated(true)} - label={Show deprecated (old) behaviors} + {hasSearchNoResult && ( + + + No behavior found for your search. Try another search, or + search for new behaviors to install. + + + ) + // TODO: Button to search behaviors + } + + {behaviors.map((behaviorMetadata, index) => ( + chooseBehavior(i18n, behaviorMetadata)} + disabled={!canBehaviorBeUsed(behaviorMetadata)} + /> + ))} + {showDeprecated && !!deprecatedBehaviors.length && ( + + Deprecated (old, prefer not to use anymore) + + )} + {showDeprecated && + deprecatedBehaviors.map((behaviorMetadata, index) => ( + chooseBehavior(i18n, behaviorMetadata)} + disabled={!canBehaviorBeUsed(behaviorMetadata)} + /> + ))} + + + {!showDeprecated ? ( + } + primary={false} + onClick={() => this._showDeprecated(true)} + label={Show deprecated (old) behaviors} + /> + ) : ( + } + primary={false} + onClick={() => this._showDeprecated(false)} + label={Show deprecated (old) behaviors} + /> + )} + + + Search New Behaviors} value="search"> + - ) : ( - } - primary={false} - onClick={() => this._showDeprecated(false)} - label={Show deprecated (old) behaviors} - /> - )} - + + )} diff --git a/newIDE/app/src/ExtensionsSearch/ExtensionInstallDialog.js b/newIDE/app/src/ExtensionsSearch/ExtensionInstallDialog.js new file mode 100644 index 0000000000..f91c78038a --- /dev/null +++ b/newIDE/app/src/ExtensionsSearch/ExtensionInstallDialog.js @@ -0,0 +1,57 @@ +// @flow +import { Trans } from '@lingui/macro'; +import React, { Component } from 'react'; +import Dialog from '../UI/Dialog'; +import FlatButton from 'material-ui/FlatButton'; +import { type ExtensionShortHeader } from '../Utils/GDevelopServices/Extension'; +import LeftLoader from '../UI/LeftLoader'; + +type Props = {| + extensionShortHeader: ExtensionShortHeader, + isInstalling: boolean, + onClose: () => void, + onInstall: (type: string, defaultName: string) => void, +|}; +type State = {||}; + +export default class ExtensionInstallDialog extends Component { + // TODO: Load extension full header + render() { + const { + isInstalling, + extensionShortHeader, + onClose, + onInstall, + } = this.props; + + return ( + Back} + primary={false} + onClick={onClose} + disabled={isInstalling} + />, + + Install in project} + primary + onClick={onInstall} + disabled={isInstalling} + /> + , + ]} + open + autoScrollBodyContent + onRequestClose={onClose} + > + {extensionShortHeader.shortDescription} + TODO: Display full description + + ); + } +} diff --git a/newIDE/app/src/ExtensionsSearch/index.js b/newIDE/app/src/ExtensionsSearch/index.js new file mode 100644 index 0000000000..e4159b064e --- /dev/null +++ b/newIDE/app/src/ExtensionsSearch/index.js @@ -0,0 +1,201 @@ +// @flow +import { t } from '@lingui/macro'; +import { Trans } from '@lingui/macro'; +import { I18n } from '@lingui/react'; +import { type I18n as I18nType } from '@lingui/core'; + +import React, { Component } from 'react'; +import SearchBar from 'material-ui-search-bar'; +import { + getExtensionsRegistry, + type ExtensionsRegistry, + type ExtensionShortHeader, + type SerializedExtension, + getExtension, +} from '../Utils/GDevelopServices/Extension'; +import { List, ListItem } from 'material-ui/List'; +import PlaceholderLoader from '../UI/PlaceholderLoader'; +import ExtensionInstallDialog from './ExtensionInstallDialog'; +import { unserializeFromJSObject } from '../Utils/Serializer'; +import { showErrorBox } from '../UI/Messages/MessageBox'; + +type Props = {| + project: gdProject, + onNewExtensionInstalled: () => void, +|}; +type State = {| + isInstalling: boolean, + selectedExtensionShortHeader: ?ExtensionShortHeader, + searchText: string, + extensionsRegistry: ?ExtensionsRegistry, +|}; + +// TODO: Factor this? +const styles = { + icon: { width: 40, height: 40 }, + disabledItem: { opacity: 0.6 }, +}; + +const addSerializedExtensionToProject = ( + project: gdProject, + serializedExtension: SerializedExtension +) => { + const newEventsFunctionsExtension = project.insertNewEventsFunctionsExtension( + serializedExtension.name, + 0 + ); + + unserializeFromJSObject( + newEventsFunctionsExtension, + serializedExtension, + 'unserializeFrom', + project + ); + // TODO: Refresh project extensions +}; + +export default class ExtensionsSearch extends Component { + state = { + isInstalling: false, + extensionsRegistry: null, + selectedExtensionShortHeader: null, + searchText: '', + }; + _searchBar = React.createRef(); + + componentDidMount() { + getExtensionsRegistry().then( + extensionsRegistry => { + this.setState({ + extensionsRegistry, + }); + }, + error => { + // handle error + } + ); + + // TODO: move this to componentDidUpdate + setTimeout(() => { + if (this._searchBar.current) this._searchBar.current.focus(); + }, 20 /* Be sure that the search bar is shown */); + } + + _install = (i18n: I18nType, extensionShortHeader: ExtensionShortHeader) => { + const { project } = this.props; + + this.setState({ + isInstalling: true, + }); + getExtension(extensionShortHeader) + .then( + serializedExtension => { + addSerializedExtensionToProject(project, serializedExtension); + this.setState({ + selectedExtensionShortHeader: null, + }); + // TODO: Display newly added behaviors + // Use context to get functions to reload extensions. + this.props.onNewExtensionInstalled(); + }, + err => { + // handle error + showErrorBox( + i18n._( + t`Unable to load the extension. Verify that your internet connection is up, and try again later.` + ), + err + ); + } + ) + .then(() => { + this.setState({ + isInstalling: false, + }); + }); + }; + + render() { + const { project } = this.props; + const { + selectedExtensionShortHeader, + extensionsRegistry, + searchText, + isInstalling, + } = this.state; + + return ( + + {({ i18n }) => ( + + { + //TODO: filtering + }} + onChange={searchText => + this.setState({ + searchText, + }) + } + ref={this._searchBar} + /> + + {!extensionsRegistry && } + {!!extensionsRegistry && + extensionsRegistry.extensionShortHeaders.map( + extensionShortHeader => { + const alreadyInstalled = project.hasEventsFunctionsExtensionNamed( + extensionShortHeader.name + ); + const disabled = alreadyInstalled; + + return ( + + {extensionShortHeader.fullName}{' '} + {alreadyInstalled && ( + (already installed) + )} + + } + secondaryText={ +

{extensionShortHeader.shortDescription}

+ } + secondaryTextLines={2} + onClick={() => + this.setState({ + selectedExtensionShortHeader: extensionShortHeader, + }) + } + style={disabled ? styles.disabledItem : undefined} + disabled={disabled} + /> + ); + } + ) + //TODO: Button to create a new extension + } +
+ {!!selectedExtensionShortHeader && ( + + this._install(i18n, selectedExtensionShortHeader) + } + onClose={() => + this.setState({ + selectedExtensionShortHeader: null, + }) + } + /> + )} +
+ )} +
+ ); + } +} diff --git a/newIDE/app/src/MainFrame/index.js b/newIDE/app/src/MainFrame/index.js index 44aae06d4f..c219910298 100644 --- a/newIDE/app/src/MainFrame/index.js +++ b/newIDE/app/src/MainFrame/index.js @@ -283,6 +283,7 @@ class MainFrame extends React.Component { }); }; + // TODO: Move this to a provider, take currentProject as parameter _loadProjectEventsFunctionsExtensions = () => { const { i18n } = this.props; if (this.props.eventsFunctionWriter && this.state.currentProject) { diff --git a/newIDE/app/src/ProjectManager/index.js b/newIDE/app/src/ProjectManager/index.js index 64dd7c272b..08e6620da0 100644 --- a/newIDE/app/src/ProjectManager/index.js +++ b/newIDE/app/src/ProjectManager/index.js @@ -501,6 +501,7 @@ export default class ProjectManager extends React.Component { newEventsFunctionsExtension.setName(newName); this.forceUpdate(); + this.props.onReloadEventsFunctionsExtensions(); }; _moveUpEventsFunctionsExtension = (index: number) => { diff --git a/newIDE/app/src/UI/LeftLoader.js b/newIDE/app/src/UI/LeftLoader.js index 25428b8c43..86b1fa2da9 100644 --- a/newIDE/app/src/UI/LeftLoader.js +++ b/newIDE/app/src/UI/LeftLoader.js @@ -2,6 +2,10 @@ import * as React from 'react'; import CircularProgress from 'material-ui/CircularProgress'; +const styles = { + progress: { marginRight: 8, verticalAlign: 'middle' }, +}; + export default ({ children, isLoading, @@ -9,8 +13,8 @@ export default ({ children: React.Node, isLoading: ?boolean, }) => ( -
- {isLoading && } + + {isLoading && } {children} -
+ ); diff --git a/newIDE/app/src/UI/RightLoader.js b/newIDE/app/src/UI/RightLoader.js index 068eddd074..8d973daa71 100644 --- a/newIDE/app/src/UI/RightLoader.js +++ b/newIDE/app/src/UI/RightLoader.js @@ -2,6 +2,10 @@ import * as React from 'react'; import CircularProgress from 'material-ui/CircularProgress'; +const styles = { + progress: { marginLeft: 8, verticalAlign: 'middle' }, +}; + export default ({ children, isLoading, @@ -9,8 +13,8 @@ export default ({ children: React.Node, isLoading: ?boolean, }) => ( -
+ {children} - {isLoading && } -
+ {isLoading && } + ); diff --git a/newIDE/app/src/Utils/GDevelopServices/ApiConfigs.js b/newIDE/app/src/Utils/GDevelopServices/ApiConfigs.js index 3f58909bb0..18d049e87c 100644 --- a/newIDE/app/src/Utils/GDevelopServices/ApiConfigs.js +++ b/newIDE/app/src/Utils/GDevelopServices/ApiConfigs.js @@ -75,3 +75,9 @@ export const GDevelopReleaseApi = { ? 'https://c8cldf4iqh.execute-api.us-east-1.amazonaws.com/dev' : 'https://api.gdevelop-app.com/release', }; + +export const GDevelopExtensionApi = { + baseUrl: isDev + ? 'https://raw.githubusercontent.com/4ian/GDevelop-extensions/master' + : 'https://raw.githubusercontent.com/4ian/GDevelop-extensions/master', +}; diff --git a/newIDE/app/src/Utils/GDevelopServices/Extension.js b/newIDE/app/src/Utils/GDevelopServices/Extension.js new file mode 100644 index 0000000000..e5a3032b12 --- /dev/null +++ b/newIDE/app/src/Utils/GDevelopServices/Extension.js @@ -0,0 +1,47 @@ +// @flow +import axios from 'axios'; +import { GDevelopExtensionApi } from './ApiConfigs'; + +export type ExtensionShortHeader = {| + shortDescription: string, + extensionNamespace: string, + fullName: string, + name: string, + version: string, + url: string, + headerUrl: string, +|}; +export type ExtensionHeader = {| + ...ExtensionShortHeader, + description: string, +|}; + +export type SerializedExtension = { + ...ExtensionHeader, +} + +export type ExtensionsRegistry = { + version: string, + extensionShortHeaders: Array, +}; + +export const getExtensionsRegistry = (): Promise => { + // TODO: Caching for a few minutes/hours? + return axios + .get(`${GDevelopExtensionApi.baseUrl}/extensions-registry.json`) + .then(response => response.data); +}; + +export const getExtensionHeader = (extensionShortHeader: ExtensionShortHeader): Promise => { + // TODO: Handle absolute urls? + return axios + .get(`${GDevelopExtensionApi.baseUrl}/${extensionShortHeader.headerUrl}`) + .then(response => response.data); +}; + +export const getExtension = (extensionHeader: ExtensionShortHeader | ExtensionHeader): Promise => { + // TODO: Handle absolute urls? + return axios + .get(`${GDevelopExtensionApi.baseUrl}/${extensionHeader.url}`) + .then(response => response.data); +}; diff --git a/newIDE/app/src/stories/index.js b/newIDE/app/src/stories/index.js index 18a565a647..27dc2557b2 100644 --- a/newIDE/app/src/stories/index.js +++ b/newIDE/app/src/stories/index.js @@ -124,6 +124,7 @@ import EventsBasedBehaviorEditor from '../EventsBasedBehaviorEditor'; import EventsBasedBehaviorEditorDialog from '../EventsBasedBehaviorEditor/EventsBasedBehaviorEditorDialog'; import BehaviorTypeSelector from '../BehaviorTypeSelector'; import ObjectTypeSelector from '../ObjectTypeSelector'; +import NewBehaviorDialog from '../BehaviorsEditor/NewBehaviorDialog'; // No i18n in this file @@ -2083,3 +2084,16 @@ storiesOf('ObjectTypeSelector', module) onChange={action('change')} /> )); + +storiesOf('NewBehaviorDialog', module) + .addDecorator(muiDecorator) + .addDecorator(i18nProviderDecorator) + .add('default, for a Sprite object', () => ( + + ));