[WIP] Add ExtensionsSearch to NewBehaviorDialog

This commit is contained in:
Florian Rival
2019-05-16 21:03:51 +01:00
parent 7d144f19c0
commit 2d850b0798
11 changed files with 482 additions and 61 deletions

View File

@@ -43,3 +43,23 @@ export const enumerateBehaviorsMetadata = (
})
);
};
export const filterEnumeratedBehaviorMetadata = (
list: Array<EnumeratedBehaviorMetadata>,
searchText: string
): Array<EnumeratedBehaviorMetadata> => {
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
);
});
};

View File

@@ -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<EnumeratedBehaviorMetadata>,
showDeprecated: boolean,
searchText: string,
|};
export default class NewBehaviorDialog extends Component<Props, State> {
state = { ...this._loadFrom(this.props.project), showDeprecated: false };
state = {
...this._loadFrom(this.props.project),
showDeprecated: false,
searchText: '',
};
_searchBar = React.createRef<SearchBar>();
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<Props, State> {
});
};
_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 = [
<FlatButton
key="close"
label={<Trans>Close</Trans>}
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<Props, State> {
);
};
const hasSearchNoResult =
!!searchText && !behaviors.length && !deprecatedBehaviors.length;
return (
<I18n>
{({ i18n }) => (
<Dialog
title={<Trans>Add a new behavior to the object</Trans>}
actions={actions}
actions={[
<FlatButton
key="close"
label={<Trans>Close</Trans>}
primary={false}
onClick={onClose}
/>,
]}
secondaryActions={<HelpButton helpPagePath="/behaviors" />}
open={open}
noMargin
autoScrollBodyContent
>
<List>
{behaviors.map((behaviorMetadata, index) => (
<BehaviorListItem
key={index}
behaviorMetadata={behaviorMetadata}
onClick={() => chooseBehavior(i18n, behaviorMetadata)}
disabled={!canBehaviorBeUsed(behaviorMetadata)}
<Tabs>
<Tab label={<Trans>Installed Behaviors</Trans>} value="installed">
<SearchBar
value={searchText}
onRequestSearch={() => {
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 && (
<Subheader>
Deprecated (old, prefer not to use anymore)
</Subheader>
)}
{showDeprecated &&
deprecatedBehaviors.map((behaviorMetadata, index) => (
<BehaviorListItem
key={index}
behaviorMetadata={behaviorMetadata}
onClick={() => chooseBehavior(i18n, behaviorMetadata)}
disabled={!canBehaviorBeUsed(behaviorMetadata)}
/>
))}
</List>
<Line justifyContent="center" alignItems="center">
{!showDeprecated ? (
<FlatButton
key="toggle-experimental"
icon={<Visibility />}
primary={false}
onClick={() => this._showDeprecated(true)}
label={<Trans>Show deprecated (old) behaviors</Trans>}
{hasSearchNoResult && (
<EmptyMessage>
<Trans>
No behavior found for your search. Try another search, or
search for new behaviors to install.
</Trans>
</EmptyMessage>
)
// TODO: Button to search behaviors
}
<List>
{behaviors.map((behaviorMetadata, index) => (
<BehaviorListItem
key={index}
behaviorMetadata={behaviorMetadata}
onClick={() => chooseBehavior(i18n, behaviorMetadata)}
disabled={!canBehaviorBeUsed(behaviorMetadata)}
/>
))}
{showDeprecated && !!deprecatedBehaviors.length && (
<Subheader>
Deprecated (old, prefer not to use anymore)
</Subheader>
)}
{showDeprecated &&
deprecatedBehaviors.map((behaviorMetadata, index) => (
<BehaviorListItem
key={index}
behaviorMetadata={behaviorMetadata}
onClick={() => chooseBehavior(i18n, behaviorMetadata)}
disabled={!canBehaviorBeUsed(behaviorMetadata)}
/>
))}
</List>
<Line justifyContent="center" alignItems="center">
{!showDeprecated ? (
<FlatButton
key="toggle-experimental"
icon={<Visibility />}
primary={false}
onClick={() => this._showDeprecated(true)}
label={<Trans>Show deprecated (old) behaviors</Trans>}
/>
) : (
<FlatButton
key="toggle-experimental"
icon={<VisibilityOff />}
primary={false}
onClick={() => this._showDeprecated(false)}
label={<Trans>Show deprecated (old) behaviors</Trans>}
/>
)}
</Line>
</Tab>
<Tab label={<Trans>Search New Behaviors</Trans>} value="search">
<ExtensionsSearch
project={project}
onNewExtensionInstalled={this._onNewExtensionInstalled}
/>
) : (
<FlatButton
key="toggle-experimental"
icon={<VisibilityOff />}
primary={false}
onClick={() => this._showDeprecated(false)}
label={<Trans>Show deprecated (old) behaviors</Trans>}
/>
)}
</Line>
</Tab>
</Tabs>
</Dialog>
)}
</I18n>

View File

@@ -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<Props, State> {
// TODO: Load extension full header
render() {
const {
isInstalling,
extensionShortHeader,
onClose,
onInstall,
} = this.props;
return (
<Dialog
title={extensionShortHeader.fullName}
actions={[
<FlatButton
key="close"
label={<Trans>Back</Trans>}
primary={false}
onClick={onClose}
disabled={isInstalling}
/>,
<LeftLoader isLoading={isInstalling}>
<FlatButton
key="install"
label={<Trans>Install in project</Trans>}
primary
onClick={onInstall}
disabled={isInstalling}
/>
</LeftLoader>,
]}
open
autoScrollBodyContent
onRequestClose={onClose}
>
{extensionShortHeader.shortDescription}
TODO: Display full description
</Dialog>
);
}
}

View File

@@ -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<Props, State> {
state = {
isInstalling: false,
extensionsRegistry: null,
selectedExtensionShortHeader: null,
searchText: '',
};
_searchBar = React.createRef<SearchBar>();
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>
{({ i18n }) => (
<React.Fragment>
<SearchBar
value={searchText}
onRequestSearch={() => {
//TODO: filtering
}}
onChange={searchText =>
this.setState({
searchText,
})
}
ref={this._searchBar}
/>
<List>
{!extensionsRegistry && <PlaceholderLoader />}
{!!extensionsRegistry &&
extensionsRegistry.extensionShortHeaders.map(
extensionShortHeader => {
const alreadyInstalled = project.hasEventsFunctionsExtensionNamed(
extensionShortHeader.name
);
const disabled = alreadyInstalled;
return (
<ListItem
key={extensionShortHeader.name}
primaryText={
<span>
{extensionShortHeader.fullName}{' '}
{alreadyInstalled && (
<Trans> (already installed)</Trans>
)}
</span>
}
secondaryText={
<p>{extensionShortHeader.shortDescription}</p>
}
secondaryTextLines={2}
onClick={() =>
this.setState({
selectedExtensionShortHeader: extensionShortHeader,
})
}
style={disabled ? styles.disabledItem : undefined}
disabled={disabled}
/>
);
}
)
//TODO: Button to create a new extension
}
</List>
{!!selectedExtensionShortHeader && (
<ExtensionInstallDialog
isInstalling={isInstalling}
extensionShortHeader={selectedExtensionShortHeader}
onInstall={() =>
this._install(i18n, selectedExtensionShortHeader)
}
onClose={() =>
this.setState({
selectedExtensionShortHeader: null,
})
}
/>
)}
</React.Fragment>
)}
</I18n>
);
}
}

View File

@@ -283,6 +283,7 @@ class MainFrame extends React.Component<Props, State> {
});
};
// TODO: Move this to a provider, take currentProject as parameter
_loadProjectEventsFunctionsExtensions = () => {
const { i18n } = this.props;
if (this.props.eventsFunctionWriter && this.state.currentProject) {

View File

@@ -501,6 +501,7 @@ export default class ProjectManager extends React.Component<Props, State> {
newEventsFunctionsExtension.setName(newName);
this.forceUpdate();
this.props.onReloadEventsFunctionsExtensions();
};
_moveUpEventsFunctionsExtension = (index: number) => {

View File

@@ -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,
}) => (
<div style={{ display: 'flex', alignItems: 'center' }}>
{isLoading && <CircularProgress size={20} style={{ marginRight: 5 }} />}
<span>
{isLoading && <CircularProgress size={20} style={styles.progress} />}
{children}
</div>
</span>
);

View File

@@ -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,
}) => (
<div style={{ display: 'flex', alignItems: 'center' }}>
<span>
{children}
{isLoading && <CircularProgress size={20} style={{ marginLeft: 5 }} />}
</div>
{isLoading && <CircularProgress size={20} style={styles.progress} />}
</span>
);

View File

@@ -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',
};

View File

@@ -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<ExtensionShortHeader>,
};
export const getExtensionsRegistry = (): Promise<ExtensionsRegistry> => {
// 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<ExtensionHeader> => {
// TODO: Handle absolute urls?
return axios
.get(`${GDevelopExtensionApi.baseUrl}/${extensionShortHeader.headerUrl}`)
.then(response => response.data);
};
export const getExtension = (extensionHeader: ExtensionShortHeader | ExtensionHeader): Promise<SerializedExtension> => {
// TODO: Handle absolute urls?
return axios
.get(`${GDevelopExtensionApi.baseUrl}/${extensionHeader.url}`)
.then(response => response.data);
};

View File

@@ -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', () => (
<NewBehaviorDialog
open
project={project}
objectType={'Sprite'}
onClose={action('on close')}
onChoose={action('on choose')}
/>
));