Add a basic Asset Store for the web-app

This commit is contained in:
Florian Rival
2020-09-09 17:59:34 +02:00
committed by Florian Rival
parent 45d7c6188b
commit 8c6b9ef044
42 changed files with 4803 additions and 688 deletions

View File

@@ -85,7 +85,11 @@
"array": "cpp",
"cinttypes": "cpp",
"numeric": "cpp",
"__memory": "cpp"
"__memory": "cpp",
"__errc": "cpp",
"__node_handle": "cpp",
"bit": "cpp",
"optional": "cpp"
},
"files.exclude": {
"Binaries/*build*": true,

View File

@@ -0,0 +1,61 @@
/*
* GDevelop Core
* Copyright 2008-2016 Florian Rival (Florian.Rival@gmail.com). All rights
* reserved. This project is released under the MIT License.
*/
#if defined(GD_IDE_ONLY)
#ifndef GDCORE_RESOURCESRENAMER_H
#define GDCORE_RESOURCESRENAMER_H
#include <set>
#include <vector>
#include "GDCore/IDE/Project/ArbitraryResourceWorker.h"
#include "GDCore/String.h"
namespace gd {
/**
* \brief Class used to rename resources (in an object, an entire project,
* etc...)
*
* \ingroup IDE
*/
class ResourcesRenamer : public gd::ArbitraryResourceWorker {
public:
/**
* @brief Constructor taking the map from old name to new name.
* @param oldToNewNames_ A map associating to a resource name the new name to
* use.
*/
ResourcesRenamer(const std::map<gd::String, gd::String>& oldToNewNames_)
: gd::ArbitraryResourceWorker(), oldToNewNames(oldToNewNames_){};
virtual ~ResourcesRenamer(){};
virtual void ExposeFile(gd::String& resourceName) override {
RenameIfNeeded(resourceName);
};
virtual void ExposeImage(gd::String& imageResourceName) override {
RenameIfNeeded(imageResourceName);
};
virtual void ExposeAudio(gd::String& audioResourceName) override {
RenameIfNeeded(audioResourceName);
};
virtual void ExposeFont(gd::String& fontResourceName) override {
RenameIfNeeded(fontResourceName);
};
private:
void RenameIfNeeded(gd::String& resourceName) {
if (oldToNewNames.find(resourceName) != oldToNewNames.end())
resourceName = oldToNewNames[resourceName];
}
std::map<gd::String, gd::String> oldToNewNames;
};
} // namespace gd
#endif // GDCORE_RESOURCESRENAMER_H
#endif

View File

@@ -0,0 +1,33 @@
/*
* GDevelop Core
* Copyright 2008-2016 Florian Rival (Florian.Rival@gmail.com). All rights
* reserved. This project is released under the MIT License.
*/
/**
* @file Tests covering common features of GDevelop Core.
*/
#include "GDCore/IDE/Project/ResourcesRenamer.h"
#include "GDCore/CommonTools.h"
#include "GDCore/Project/Project.h"
#include "catch.hpp"
TEST_CASE("ResourcesRenamer", "[common]") {
SECTION("It renames resources that are exposed") {
std::map<gd::String, gd::String> renamings = {
{"Resource1", "RenamedResource1"}};
gd::ResourcesRenamer resourcesRenamer(renamings);
gd::Project project;
project.GetPlatformSpecificAssets().Set(
"android", "some-icon", "Resource1");
project.GetPlatformSpecificAssets().Set(
"android", "some-other-icon", "Resource2");
project.ExposeResources(resourcesRenamer);
REQUIRE(project.GetPlatformSpecificAssets().Get("android", "some-icon") ==
"RenamedResource1");
REQUIRE(project.GetPlatformSpecificAssets().Get(
"android", "some-other-icon") == "Resource2");
}
}

View File

@@ -2002,6 +2002,12 @@ interface ResourcesMergingHelper {
void SetBaseDirectory([Const] DOMString basePath);
[Ref] MapStringString GetAllResourcesOldAndNewFilename();
};
ResourcesMergingHelper implements ArbitraryResourceWorker;
interface ResourcesRenamer {
void ResourcesRenamer([Const, Ref] MapStringString oldToNewNames);
};
ResourcesRenamer implements ArbitraryResourceWorker;
interface ProjectResourcesCopier {
boolean STATIC_CopyAllResourcesTo([Ref] Project project,
@@ -2020,6 +2026,7 @@ interface ResourcesInUseHelper {
[Ref] SetString GetAllFonts();
[Ref] SetString GetAll([Const] DOMString resourceType);
};
ResourcesInUseHelper implements ArbitraryResourceWorker;
interface LayoutEditorCanvasOptions {
void LayoutEditorCanvasOptions();

View File

@@ -37,6 +37,7 @@
#include <GDCore/IDE/Project/ProjectResourcesCopier.h>
#include <GDCore/IDE/Project/ResourcesInUseHelper.h>
#include <GDCore/IDE/Project/ResourcesMergingHelper.h>
#include <GDCore/IDE/Project/ResourcesRenamer.h>
#include <GDCore/IDE/WholeProjectRefactorer.h>
#include <GDCore/Project/Behavior.h>
#include <GDCore/Project/Effect.h>

View File

@@ -248,12 +248,6 @@ type ParticleEmitterObject_RendererType = 0 | 1 | 2`
'declare class gdHighestZOrderFinder extends gdInitialInstanceFunctor {',
'types/gdhighestzorderfinder.js'
);
shell.sed(
'-i',
'declare class gdResourcesInUseHelper {',
'declare class gdResourcesInUseHelper extends gdArbitraryResourceWorker {',
'types/gdresourcesinusehelper.js'
);
// Rename classes from GDJS:
shell.sed(

View File

@@ -1,5 +1,5 @@
// Automatically generated by GDevelop.js/scripts/generate-types.js
declare class gdResourcesMergingHelper {
declare class gdResourcesMergingHelper extends gdArbitraryResourceWorker {
constructor(fs: gdAbstractFileSystem): void;
setBaseDirectory(basePath: string): void;
getAllResourcesOldAndNewFilename(): gdMapStringString;

View File

@@ -0,0 +1,6 @@
// Automatically generated by GDevelop.js/scripts/generate-types.js
declare class gdResourcesRenamer extends gdArbitraryResourceWorker {
constructor(oldToNewNames: gdMapStringString): void;
delete(): void;
ptr: number;
};

View File

@@ -160,6 +160,7 @@ declare class libGDevelop {
ArbitraryResourceWorker: Class<gdArbitraryResourceWorker>;
ArbitraryResourceWorkerJS: Class<gdArbitraryResourceWorkerJS>;
ResourcesMergingHelper: Class<gdResourcesMergingHelper>;
ResourcesRenamer: Class<gdResourcesRenamer>;
ProjectResourcesCopier: Class<gdProjectResourcesCopier>;
ResourcesInUseHelper: Class<gdResourcesInUseHelper>;
LayoutEditorCanvasOptions: Class<gdLayoutEditorCanvasOptions>;

View File

@@ -1,459 +0,0 @@
// flow-typed signature: 2dd68ce1a3d8938f974e88d46cbf55d1
// flow-typed version: c7c67b81c1/jest_v19.x.x/flow_>=v0.16.x
type JestMockFn = {
(...args: Array<any>): any,
/**
* An object for introspecting mock calls
*/
mock: {
/**
* An array that represents all calls that have been made into this mock
* function. Each call is represented by an array of arguments that were
* passed during the call.
*/
calls: Array<Array<any>>,
/**
* An array that contains all the object instances that have been
* instantiated from this mock function.
*/
instances: mixed,
},
/**
* Resets all information stored in the mockFn.mock.calls and
* mockFn.mock.instances arrays. Often this is useful when you want to clean
* up a mock's usage data between two assertions.
*/
mockClear(): Function,
/**
* Resets all information stored in the mock. This is useful when you want to
* completely restore a mock back to its initial state.
*/
mockReset(): Function,
/**
* Accepts a function that should be used as the implementation of the mock.
* The mock itself will still record all calls that go into and instances
* that come from itself -- the only difference is that the implementation
* will also be executed when the mock is called.
*/
mockImplementation(fn: Function): JestMockFn,
/**
* Accepts a function that will be used as an implementation of the mock for
* one call to the mocked function. Can be chained so that multiple function
* calls produce different results.
*/
mockImplementationOnce(fn: Function): JestMockFn,
/**
* Just a simple sugar function for returning `this`
*/
mockReturnThis(): void,
/**
* Deprecated: use jest.fn(() => value) instead
*/
mockReturnValue(value: any): JestMockFn,
/**
* Sugar for only returning a value once inside your mock
*/
mockReturnValueOnce(value: any): JestMockFn,
}
type JestAsymmetricEqualityType = {
/**
* A custom Jasmine equality tester
*/
asymmetricMatch(value: mixed): boolean,
}
type JestCallsType = {
allArgs(): mixed,
all(): mixed,
any(): boolean,
count(): number,
first(): mixed,
mostRecent(): mixed,
reset(): void,
}
type JestClockType = {
install(): void,
mockDate(date: Date): void,
tick(milliseconds?:number): void,
uninstall(): void,
}
type JestMatcherResult = {
message?: string | ()=>string,
pass: boolean,
}
type JestMatcher = (actual: any, expected: any) => JestMatcherResult;
type JestExpectType = {
not: JestExpectType,
/**
* If you have a mock function, you can use .lastCalledWith to test what
* arguments it was last called with.
*/
lastCalledWith(...args: Array<any>): void,
/**
* toBe just checks that a value is what you expect. It uses === to check
* strict equality.
*/
toBe(value: any): void,
/**
* Use .toHaveBeenCalled to ensure that a mock function got called.
*/
toBeCalled(): void,
/**
* Use .toBeCalledWith to ensure that a mock function was called with
* specific arguments.
*/
toBeCalledWith(...args: Array<any>): void,
/**
* Using exact equality with floating point numbers is a bad idea. Rounding
* means that intuitive things fail.
*/
toBeCloseTo(num: number, delta: any): void,
/**
* Use .toBeDefined to check that a variable is not undefined.
*/
toBeDefined(): void,
/**
* Use .toBeFalsy when you don't care what a value is, you just want to
* ensure a value is false in a boolean context.
*/
toBeFalsy(): void,
/**
* To compare floating point numbers, you can use toBeGreaterThan.
*/
toBeGreaterThan(number: number): void,
/**
* To compare floating point numbers, you can use toBeGreaterThanOrEqual.
*/
toBeGreaterThanOrEqual(number: number): void,
/**
* To compare floating point numbers, you can use toBeLessThan.
*/
toBeLessThan(number: number): void,
/**
* To compare floating point numbers, you can use toBeLessThanOrEqual.
*/
toBeLessThanOrEqual(number: number): void,
/**
* Use .toBeInstanceOf(Class) to check that an object is an instance of a
* class.
*/
toBeInstanceOf(cls: Class<*>): void,
/**
* .toBeNull() is the same as .toBe(null) but the error messages are a bit
* nicer.
*/
toBeNull(): void,
/**
* Use .toBeTruthy when you don't care what a value is, you just want to
* ensure a value is true in a boolean context.
*/
toBeTruthy(): void,
/**
* Use .toBeUndefined to check that a variable is undefined.
*/
toBeUndefined(): void,
/**
* Use .toContain when you want to check that an item is in a list. For
* testing the items in the list, this uses ===, a strict equality check.
*/
toContain(item: any): void,
/**
* Use .toContainEqual when you want to check that an item is in a list. For
* testing the items in the list, this matcher recursively checks the
* equality of all fields, rather than checking for object identity.
*/
toContainEqual(item: any): void,
/**
* Use .toEqual when you want to check that two objects have the same value.
* This matcher recursively checks the equality of all fields, rather than
* checking for object identity.
*/
toEqual(value: any): void,
/**
* Use .toHaveBeenCalled to ensure that a mock function got called.
*/
toHaveBeenCalled(): void,
/**
* Use .toHaveBeenCalledTimes to ensure that a mock function got called exact
* number of times.
*/
toHaveBeenCalledTimes(number: number): void,
/**
* Use .toHaveBeenCalledWith to ensure that a mock function was called with
* specific arguments.
*/
toHaveBeenCalledWith(...args: Array<any>): void,
/**
* If you have a mock function, you can use .toHaveBeenLastCalledWith to test what
* arguments it was last called with.
*/
toHaveBeenLastCalledWith(...args: Array<any>): void,
/**
* Check that an object has a .length property and it is set to a certain
* numeric value.
*/
toHaveLength(number: number): void,
/**
*
*/
toHaveProperty(propPath: string, value?: any): void,
/**
* Use .toMatch to check that a string matches a regular expression.
*/
toMatch(regexp: RegExp): void,
/**
* Use .toMatchObject to check that a javascript object matches a subset of the properties of an object.
*/
toMatchObject(object: Object): void,
/**
* This ensures that a React component matches the most recent snapshot.
*/
toMatchSnapshot(name?: string): void,
/**
* Use .toThrow to test that a function throws when it is called.
*/
toThrow(message?: string | Error): void,
/**
* Use .toThrowError to test that a function throws a specific error when it
* is called. The argument can be a string for the error message, a class for
* the error, or a regex that should match the error.
*/
toThrowError(message?: string | Error | RegExp): void,
/**
* Use .toThrowErrorMatchingSnapshot to test that a function throws a error
* matching the most recent snapshot when it is called.
*/
toThrowErrorMatchingSnapshot(): void,
}
type JestObjectType = {
/**
* Disables automatic mocking in the module loader.
*
* After this method is called, all `require()`s will return the real
* versions of each module (rather than a mocked version).
*/
disableAutomock(): JestObjectType,
/**
* An un-hoisted version of disableAutomock
*/
autoMockOff(): JestObjectType,
/**
* Enables automatic mocking in the module loader.
*/
enableAutomock(): JestObjectType,
/**
* An un-hoisted version of enableAutomock
*/
autoMockOn(): JestObjectType,
/**
* Clears the mock.calls and mock.instances properties of all mocks.
* Equivalent to calling .mockClear() on every mocked function.
*/
clearAllMocks(): JestObjectType,
/**
* Resets the state of all mocks. Equivalent to calling .mockReset() on every
* mocked function.
*/
resetAllMocks(): JestObjectType,
/**
* Removes any pending timers from the timer system.
*/
clearAllTimers(): void,
/**
* The same as `mock` but not moved to the top of the expectation by
* babel-jest.
*/
doMock(moduleName: string, moduleFactory?: any): JestObjectType,
/**
* The same as `unmock` but not moved to the top of the expectation by
* babel-jest.
*/
dontMock(moduleName: string): JestObjectType,
/**
* Returns a new, unused mock function. Optionally takes a mock
* implementation.
*/
fn(implementation?: Function): JestMockFn,
/**
* Determines if the given function is a mocked function.
*/
isMockFunction(fn: Function): boolean,
/**
* Given the name of a module, use the automatic mocking system to generate a
* mocked version of the module for you.
*/
genMockFromModule(moduleName: string): any,
/**
* Mocks a module with an auto-mocked version when it is being required.
*
* The second argument can be used to specify an explicit module factory that
* is being run instead of using Jest's automocking feature.
*
* The third argument can be used to create virtual mocks -- mocks of modules
* that don't exist anywhere in the system.
*/
mock(moduleName: string, moduleFactory?: any, options?: Object): JestObjectType,
/**
* Resets the module registry - the cache of all required modules. This is
* useful to isolate modules where local state might conflict between tests.
*/
resetModules(): JestObjectType,
/**
* Exhausts the micro-task queue (usually interfaced in node via
* process.nextTick).
*/
runAllTicks(): void,
/**
* Exhausts the macro-task queue (i.e., all tasks queued by setTimeout(),
* setInterval(), and setImmediate()).
*/
runAllTimers(): void,
/**
* Exhausts all tasks queued by setImmediate().
*/
runAllImmediates(): void,
/**
* Executes only the macro task queue (i.e. all tasks queued by setTimeout()
* or setInterval() and setImmediate()).
*/
runTimersToTime(msToRun: number): void,
/**
* Executes only the macro-tasks that are currently pending (i.e., only the
* tasks that have been queued by setTimeout() or setInterval() up to this
* point)
*/
runOnlyPendingTimers(): void,
/**
* Explicitly supplies the mock object that the module system should return
* for the specified module. Note: It is recommended to use jest.mock()
* instead.
*/
setMock(moduleName: string, moduleExports: any): JestObjectType,
/**
* Indicates that the module system should never return a mocked version of
* the specified module from require() (e.g. that it should always return the
* real module).
*/
unmock(moduleName: string): JestObjectType,
/**
* Instructs Jest to use fake versions of the standard timer functions
* (setTimeout, setInterval, clearTimeout, clearInterval, nextTick,
* setImmediate and clearImmediate).
*/
useFakeTimers(): JestObjectType,
/**
* Instructs Jest to use the real versions of the standard timer functions.
*/
useRealTimers(): JestObjectType,
/**
* Creates a mock function similar to jest.fn but also tracks calls to
* object[methodName].
*/
spyOn(object: Object, methodName: string): JestMockFn,
}
type JestSpyType = {
calls: JestCallsType,
}
/** Runs this function after every test inside this context */
declare function afterEach(fn: Function): void;
/** Runs this function before every test inside this context */
declare function beforeEach(fn: Function): void;
/** Runs this function after all tests have finished inside this context */
declare function afterAll(fn: Function): void;
/** Runs this function before any tests have started inside this context */
declare function beforeAll(fn: Function): void;
/** A context for grouping tests together */
declare function describe(name: string, fn: Function): void;
/** An individual test unit */
declare var it: {
/**
* An individual test unit
*
* @param {string} Name of Test
* @param {Function} Test
*/
(name: string, fn?: Function): ?Promise<void>,
/**
* Only run this test
*
* @param {string} Name of Test
* @param {Function} Test
*/
only(name: string, fn?: Function): ?Promise<void>,
/**
* Skip running this test
*
* @param {string} Name of Test
* @param {Function} Test
*/
skip(name: string, fn?: Function): ?Promise<void>,
/**
* Run the test concurrently
*
* @param {string} Name of Test
* @param {Function} Test
*/
concurrent(name: string, fn?: Function): ?Promise<void>,
};
declare function fit(name: string, fn: Function): ?Promise<void>;
/** An individual test unit */
declare var test: typeof it;
/** A disabled group of tests */
declare var xdescribe: typeof describe;
/** A focused group of tests */
declare var fdescribe: typeof describe;
/** A disabled individual test */
declare var xit: typeof it;
/** A disabled individual test */
declare var xtest: typeof it;
/** The expect function is used every time you want to test a value */
declare var expect: {
/** The object that you want to make assertions against */
(value: any): JestExpectType,
/** Add additional Jasmine matchers to Jest's roster */
extend(matchers: {[name:string]: JestMatcher}): void,
/** Add a module that formats application-specific data structures. */
addSnapshotSerializer(serializer: (input: Object) => string): void,
assertions(expectedAssertions: number): void,
any(value: mixed): JestAsymmetricEqualityType,
anything(): void,
arrayContaining(value: Array<mixed>): void,
objectContaining(value: Object): void,
/** Matches any received string that contains the exact expected string. */
stringContaining(value: string): void,
stringMatching(value: string | RegExp): void,
};
// TODO handle return type
// http://jasmine.github.io/2.4/introduction.html#section-Spies
declare function spyOn(value: mixed, method: string): Object;
/** Holds all functions related to manipulating test runner */
declare var jest: JestObjectType
/**
* The global Jasmine object, this is generally not exposed as the public API,
* using features inside here could break in later versions of Jest.
*/
declare var jasmine: {
DEFAULT_TIMEOUT_INTERVAL: number,
any(value: mixed): JestAsymmetricEqualityType,
anything(): void,
arrayContaining(value: Array<mixed>): void,
clock(): JestClockType,
createSpy(name: string): JestSpyType,
createSpyObj(baseName: string, methodNames: Array<string>): {[methodName: string]: JestSpyType},
objectContaining(value: Object): void,
stringMatching(value: string): void,
}

1206
newIDE/app/flow-typed/npm/jest_v24.x.x.js vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -15054,6 +15054,27 @@
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
},
"js-worker-search": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/js-worker-search/-/js-worker-search-1.4.1.tgz",
"integrity": "sha512-5KCio6angmP+AXX8X2pPtjw1h3BCrmEmwGv2MdkXhl3+7g/2NTHOMhUh8bZqacp8R9LjhTT2bGr+f7RdB8KFZw==",
"requires": {
"flow-bin": "^0.50.0",
"uuid": "^2.0.1"
},
"dependencies": {
"flow-bin": {
"version": "0.50.0",
"resolved": "https://registry.npmjs.org/flow-bin/-/flow-bin-0.50.0.tgz",
"integrity": "sha1-1M2yQw3uGjWZ8Otv5VEUbjAnJWo="
},
"uuid": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-2.0.3.tgz",
"integrity": "sha1-Z+LoY3lyFVMN/zGOW/nc6/1Hsho="
}
}
},
"js-yaml": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.0.tgz",

View File

@@ -43,6 +43,7 @@
"element-closest": "2.0.2",
"firebase": "^6.1.0",
"fontfaceobserver": "2.0.13",
"js-worker-search": "^1.4.1",
"jss-rtl": "^0.3.0",
"keen-tracking": "1.1.3",
"lodash": "4.17.4",

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-eval' 'unsafe-inline' https://api.gdevelop-app.com https://cdn.jsdelivr.net/npm/docsearch.js@2/dist/cdn/docsearch.min.js https://api.keen.io https://apis.google.com">
<meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-eval' 'unsafe-inline' blob: https://api.gdevelop-app.com https://cdn.jsdelivr.net/npm/docsearch.js@2/dist/cdn/docsearch.min.js https://api.keen.io https://apis.google.com">
<link rel="apple-touch-icon" href="%PUBLIC_URL%/apple-touch-icon.png">
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon-256.png">
<title>GDevelop 5</title>

View File

@@ -0,0 +1,79 @@
// @flow
import * as React from 'react';
import { type AssetShortHeader } from '../Utils/GDevelopServices/Asset';
import ButtonBase from '@material-ui/core/ButtonBase';
import Text from '../UI/Text';
const paddingSize = 10;
const styles = {
previewBackground: {
background: 'url("res/transparentback.png") repeat',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
},
previewImage: {
objectFit: 'contain',
verticalAlign: 'middle',
pointerEvents: 'none',
},
icon: {
color: '#fff',
},
cardContainer: {
overflow: 'hidden',
position: 'relative',
},
titleContainer: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: 48,
color: '#fff',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
backgroundColor: 'rgb(0,0,0,0.5)',
},
title: {
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
},
};
type Props = {|
size: number,
assetShortHeader: AssetShortHeader,
onOpenDetails: () => void,
|};
export const AssetCard = ({ assetShortHeader, onOpenDetails, size }: Props) => {
return (
<ButtonBase onClick={onOpenDetails} focusRipple>
<div style={{ ...styles.cardContainer, width: size, height: size }}>
<div style={{ ...styles.previewBackground, width: size, height: size }}>
<img
key={assetShortHeader.previewImageUrls[0]}
style={{
...styles.previewImage,
maxWidth: 128 - 2 * paddingSize,
maxHeight: 128 - 2 * paddingSize,
}}
src={assetShortHeader.previewImageUrls[0]}
alt={assetShortHeader.name}
/>
</div>
<div style={styles.titleContainer}>
<Text noMargin style={styles.title}>
{assetShortHeader.name}
</Text>
<Text noMargin style={styles.title} size="body2">
{assetShortHeader.shortDescription}
</Text>
</div>
</div>
</ButtonBase>
);
};

View File

@@ -0,0 +1,205 @@
// @flow
import { Trans } from '@lingui/macro';
import * as React from 'react';
import { Column } from '../UI/Grid';
import Text from '../UI/Text';
import Chip from '@material-ui/core/Chip';
import RaisedButton from '../UI/RaisedButton';
import {
type AssetShortHeader,
type Asset,
getAsset,
} from '../Utils/GDevelopServices/Asset';
import LeftLoader from '../UI/LeftLoader';
import PlaceholderLoader from '../UI/PlaceholderLoader';
import PlaceholderError from '../UI/PlaceholderError';
import CustomizationFields from './CustomizationFields';
import {
type ResourceSource,
type ChooseResourceFunction,
} from '../ResourcesList/ResourceSource.flow';
import { type ResourceExternalEditor } from '../ResourcesList/ResourceExternalEditor.flow';
import Add from '@material-ui/icons/Add';
import { ResponsiveLineStackLayout } from '../UI/Layout';
import { ResponsiveWindowMeasurer } from '../UI/Reponsive/ResponsiveWindowMeasurer';
import Dialog from '../UI/Dialog';
import FlatButton from '../UI/FlatButton';
const styles = {
previewBackground: {
background: 'url("res/transparentback.png") repeat',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
padding: 10,
},
verticalPreviewBackground: {
width: 250,
},
horizontalPreviewBackground: {
height: 170,
},
chip: {
marginBottom: 2,
marginRight: 2,
},
inlineText: {
display: 'inline-block',
},
previewImage: {
maxWidth: '100%',
maxHeight: '100%',
verticalAlign: 'middle',
pointerEvents: 'none',
objectFit: 'contain',
},
};
type Props = {|
project: gdProject,
layout: ?gdLayout,
objectsContainer: gdObjectsContainer,
resourceSources: Array<ResourceSource>,
onChooseResource: ChooseResourceFunction,
resourceExternalEditors: Array<ResourceExternalEditor>,
assetShortHeader: AssetShortHeader,
onAdd: () => void,
onClose: () => void,
canInstall: boolean,
isBeingInstalled: boolean,
|};
export const AssetDetails = ({
project,
layout,
objectsContainer,
resourceSources,
onChooseResource,
resourceExternalEditors,
assetShortHeader,
onAdd,
onClose,
canInstall,
isBeingInstalled,
}: Props) => {
const [asset, setAsset] = React.useState<?Asset>(null);
const [error, setError] = React.useState<?Error>(null);
const loadAsset = React.useCallback(
() => {
(async () => {
try {
const loadedAsset = await getAsset(assetShortHeader);
setAsset(loadedAsset);
} catch (error) {
console.log('Error while loading asset:', error);
setError(error);
// TODO: handle error
}
})();
},
[assetShortHeader]
);
React.useEffect(
() => {
loadAsset();
},
[loadAsset]
);
return (
<Dialog
open
title={<Trans>Add an object from the store</Trans>}
onRequestClose={onClose}
actions={[
<FlatButton key="back" label={<Trans>Back</Trans>} onClick={onClose} />,
<LeftLoader
isLoading={isBeingInstalled || (!asset && !error)}
key="install"
>
<RaisedButton
primary
icon={<Add />}
label={<Trans>Add to the game</Trans>}
onClick={onAdd}
disabled={!canInstall || isBeingInstalled || !asset}
/>
</LeftLoader>,
]}
>
<Column expand noMargin>
<ResponsiveLineStackLayout noMargin>
<ResponsiveWindowMeasurer>
{windowWidth => (
<div
style={{
...styles.previewBackground,
...(windowWidth === 'small'
? styles.horizontalPreviewBackground
: styles.verticalPreviewBackground),
}}
>
<img
style={styles.previewImage}
src={assetShortHeader.previewImageUrls[0]}
alt={assetShortHeader.name}
/>
</div>
)}
</ResponsiveWindowMeasurer>
<Column expand>
<div>
<Text size="title" style={styles.inlineText}>
{assetShortHeader.name}
</Text>{' '}
-{' '}
<Text size="body" style={styles.inlineText}>
{assetShortHeader.shortDescription}
</Text>
</div>
<span>
{assetShortHeader.tags.map(tag => (
<Chip size="small" style={styles.chip} label={tag} key={tag} />
))}
</span>
{asset ? (
<React.Fragment>
<Text size="body">
<Trans>By {asset.authors.join(', ')}</Trans>
</Text>
<Text size="body">
<Trans>License: {asset.license}</Trans>
</Text>
<Text size="body">{asset.description}</Text>
</React.Fragment>
) : error ? (
<PlaceholderError onRetry={loadAsset}>
<Trans>
Error while loading the asset. Verify your internet connection
or try again later.
</Trans>
</PlaceholderError>
) : (
<PlaceholderLoader />
)}
</Column>
</ResponsiveLineStackLayout>
{asset ? (
<Column>
<CustomizationFields
project={project}
layout={layout}
objectsContainer={objectsContainer}
resourceSources={resourceSources}
onChooseResource={onChooseResource}
resourceExternalEditors={resourceExternalEditors}
asset={asset}
/>
</Column>
) : null}
</Column>
</Dialog>
);
};

View File

@@ -0,0 +1,302 @@
// @flow
import * as React from 'react';
import {
type FiltersState,
type ChosenCategory,
useFilters,
} from './FiltersChooser';
import {
type AssetShortHeader,
type Filters,
listAllAssets,
} from '../Utils/GDevelopServices/Asset';
import shuffle from 'lodash/shuffle';
import SearchApi from 'js-worker-search';
const defaultSearchText = '';
type AssetStoreState = {|
filters: ?Filters,
searchResults: ?Array<AssetShortHeader>,
fetchAssetsAndFilters: () => void,
error: ?Error,
searchText: string,
setSearchText: string => void,
filtersState: FiltersState,
|};
export const AssetStoreContext = React.createContext<AssetStoreState>({
filters: null,
searchResults: null,
fetchAssetsAndFilters: () => {},
error: null,
searchText: '',
setSearchText: () => {},
filtersState: {
chosenFilters: new Set(),
addFilter: () => {},
removeFilter: () => {},
chosenCategory: null,
setChosenCategory: () => {},
},
});
type AssetStoreStateProviderProps = {|
children: React.Node,
|};
const filterAssetShortHeaders = (
assetShortHeaders: ?Array<AssetShortHeader>,
chosenCategory: ?ChosenCategory,
chosenFilters: Set<string>
) => {
if (!assetShortHeaders) return null;
const startTime = performance.now();
const filteredAssetShortHeaders = assetShortHeaders
.filter(({ tags }) => {
if (!chosenCategory) return true;
const hasChosenCategoryTag = tags.some(
tag => tag === chosenCategory.node.name
);
if (!hasChosenCategoryTag) return false; // Asset is not in the selected category
for (const parentNode of chosenCategory.parentNodes) {
const hasParentCategoryTag = tags.some(tag => tag === parentNode.name);
if (!hasParentCategoryTag) return false; // Asset is not in the the parent(s) of the selected category
}
return true;
})
.filter(({ tags }) => {
return (
chosenFilters.size === 0 || tags.some(tag => chosenFilters.has(tag))
);
});
const totalTime = performance.now() - startTime;
console.info(
`Filtered assets by category/filters in ${totalTime.toFixed(3)}ms.`
);
return filteredAssetShortHeaders;
};
export const AssetStoreStateProvider = ({
children,
}: AssetStoreStateProviderProps) => {
const searchApiRef = React.useRef<?any>(null);
const [
searchResults,
setSearchResults,
] = React.useState<?Array<AssetShortHeader>>(null);
const [assetShortHeadersById, setAssetShortHeadersById] = React.useState<?{
[string]: AssetShortHeader,
}>(null);
const [filters, setFilters] = React.useState<?Filters>(null);
const [error, setError] = React.useState<?Error>(null);
const isLoading = React.useRef<boolean>(false);
const [searchText, setSearchText] = React.useState(defaultSearchText);
const filtersState = useFilters();
const fetchAssetsAndFilters = React.useCallback(
() => {
// Don't attempt to load again assets and filters if they
// were loaded already.
if (assetShortHeadersById || isLoading.current) return;
(async () => {
setError(null);
isLoading.current = true;
try {
const { assetShortHeaders, filters } = await listAllAssets();
const assetShortHeadersById = {};
assetShortHeaders.forEach(assetShortHeader => {
assetShortHeadersById[assetShortHeader.id] = assetShortHeader;
});
console.info(
`Loaded ${assetShortHeaders.length} assets from the asset store.`
);
setAssetShortHeadersById(assetShortHeadersById);
setFilters(filters);
} catch (error) {
setError(error);
}
isLoading.current = false;
})();
},
[assetShortHeadersById, isLoading]
);
React.useEffect(
() => {
// Don't attempt to load again assets and filters if they
// were loaded already.
if (assetShortHeadersById || isLoading.current) return;
const timeoutId = setTimeout(() => {
console.info('Pre-fetching assets from asset store...');
fetchAssetsAndFilters();
}, 6000);
return () => clearTimeout(timeoutId);
},
[fetchAssetsAndFilters, assetShortHeadersById, isLoading]
);
// Keep in memory a list of all the assets, shuffled for
// easing random discovery of assets when no search is done.
const suffledAssetShortHeaders: ?Array<AssetShortHeader> = React.useMemo(
() => {
if (!assetShortHeadersById) return null;
return shuffle(
Object.keys(assetShortHeadersById).map(id => assetShortHeadersById[id])
);
},
[assetShortHeadersById]
);
// Index assets that have been loaded.
React.useEffect(
() => {
if (!assetShortHeadersById) {
// Nothing to index - yet.
return;
}
const startTime = performance.now();
if (searchApiRef.current) {
searchApiRef.current.terminate();
searchApiRef.current = null;
}
try {
const newSearchApi = new SearchApi();
const allIds = Object.keys(assetShortHeadersById);
allIds.forEach(id => {
const assetShortHeader = assetShortHeadersById[id];
newSearchApi.indexDocument(
assetShortHeader.id,
assetShortHeader.name +
'\n' +
assetShortHeader.shortDescription +
'\n' +
assetShortHeader.tags.join(', ')
);
});
const totalTime = performance.now() - startTime;
console.info(
`Indexed ${allIds.length} assets in ${totalTime.toFixed(3)}ms.`
);
searchApiRef.current = newSearchApi;
} catch (error) {
console.error('Error while indexing assets: ', error);
}
},
[assetShortHeadersById]
);
// Update the search results according to the assets/search term/
// chosen category and chosen filters.
const searchApi = searchApiRef.current;
const { chosenCategory, chosenFilters } = filtersState;
React.useEffect(
() => {
let discardSearch = false;
if (!searchText) {
setSearchResults(
filterAssetShortHeaders(
suffledAssetShortHeaders,
chosenCategory,
chosenFilters
)
);
} else {
if (!assetShortHeadersById || !searchApi) {
console.info(
'Search for assets skipped because assets are not ready - will be retried when ready'
);
return;
}
const startTime = performance.now();
searchApi
.search(searchText)
.then((partialSearchResultIds: Array<string>) => {
if (discardSearch) {
console.info(
'Discarding search results as a new search was launched.'
);
return;
}
const partialSearchResults = partialSearchResultIds
.map(id => assetShortHeadersById[id])
.filter(Boolean);
const totalTime = performance.now() - startTime;
console.info(
`Found ${
partialSearchResults.length
} assets in ${totalTime.toFixed(3)}ms.`
);
setSearchResults(
filterAssetShortHeaders(
partialSearchResults,
chosenCategory,
chosenFilters
)
);
});
}
return () => {
// Effect is being destroyed - meaning that a new search was launched.
// Cancel this one.
discardSearch = true;
};
},
[
suffledAssetShortHeaders,
assetShortHeadersById,
searchText,
chosenCategory,
chosenFilters,
searchApi,
]
);
const assetStoreState = React.useMemo(
() => ({
searchResults,
fetchAssetsAndFilters,
filters,
error,
searchText,
setSearchText,
filtersState,
}),
[
searchResults,
error,
filters,
searchText,
filtersState,
fetchAssetsAndFilters,
]
);
return (
<AssetStoreContext.Provider value={assetStoreState}>
{children}
</AssetStoreContext.Provider>
);
};

View File

@@ -0,0 +1,120 @@
// @flow
import * as React from 'react';
import { type Asset } from '../Utils/GDevelopServices/Asset';
import { ColumnStackLayout } from '../UI/Layout';
import { unserializeFromJSObject } from '../Utils/Serializer';
import flatten from 'lodash/flatten';
import ParameterRenderingService from '../EventsSheet/ParameterRenderingService';
import {
type ResourceSource,
type ChooseResourceFunction,
} from '../ResourcesList/ResourceSource.flow';
import { type ResourceExternalEditor } from '../ResourcesList/ResourceExternalEditor.flow';
const gd: libGDevelop = global.gd;
type Props = {|
project: gdProject,
layout: ?gdLayout,
objectsContainer: gdObjectsContainer,
resourceSources: Array<ResourceSource>,
onChooseResource: ChooseResourceFunction,
resourceExternalEditors: Array<ResourceExternalEditor>,
asset: Asset,
|};
export default function CustomizationFields({
asset,
project,
layout,
objectsContainer,
resourceSources,
onChooseResource,
resourceExternalEditors,
}: Props) {
const [allParameterMetadata, setAllParameterMetadata] = React.useState<
Array<gdParameterMetadata>
>([]);
React.useEffect(
() => {
// TODO: refactor into InstallAsset?
setAllParameterMetadata(
flatten(
asset.objectAssets.map(objectAsset => {
return flatten(
objectAsset.customization
.map(customization => {
if (customization.events) {
return customization.parameters.map(
serializedParameterMetadata => {
const parameterMetadata = new gd.ParameterMetadata();
unserializeFromJSObject(
parameterMetadata,
serializedParameterMetadata
);
return parameterMetadata;
}
);
} else if (customization.behaviorName) {
return customization.properties.map(
serializedParameterMetadata => {
const parameterMetadata = new gd.ParameterMetadata();
unserializeFromJSObject(
parameterMetadata,
serializedParameterMetadata
);
return parameterMetadata;
}
);
}
return null;
})
.filter(Boolean)
);
})
).filter(Boolean)
);
return () => {
allParameterMetadata.forEach(parameterMetadata => {
parameterMetadata.delete();
});
setAllParameterMetadata([]);
};
},
[asset]
);
return (
<ColumnStackLayout>
{allParameterMetadata.map((parameterMetadata, index) => {
const parameterMetadataType = parameterMetadata.getType();
const ParameterComponent = ParameterRenderingService.getParameterComponent(
parameterMetadataType
);
return (
<ParameterComponent
parameterMetadata={parameterMetadata}
value={'TODO'}
onChange={value => {
/* TODO */
}}
project={project}
scope={{
layout,
}}
globalObjectsContainer={project}
objectsContainer={objectsContainer}
key={index}
parameterRenderingService={ParameterRenderingService}
resourceSources={resourceSources}
onChooseResource={onChooseResource}
resourceExternalEditors={resourceExternalEditors}
/>
);
})}
</ColumnStackLayout>
);
}

View File

@@ -0,0 +1,200 @@
// @flow
import { Trans } from '@lingui/macro';
import * as React from 'react';
import {
type Filters,
type TagsTreeNode,
} from '../Utils/GDevelopServices/Asset';
import TreeView from '@material-ui/lab/TreeView';
import TreeItem from '@material-ui/lab/TreeItem';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import ChevronRightIcon from '@material-ui/icons/ChevronRight';
import Subheader from '../UI/Subheader';
import InlineCheckbox from '../UI/InlineCheckbox';
import { ColumnStackLayout } from '../UI/Layout';
import PlaceholderLoader from '../UI/PlaceholderLoader';
import EmptyMessage from '../UI/EmptyMessage';
export type ChosenCategory = {|
node: TagsTreeNode,
parentNodes: Array<TagsTreeNode>,
|};
export type FiltersState = {|
chosenFilters: Set<string>,
addFilter: string => void,
removeFilter: string => void,
chosenCategory: ?ChosenCategory,
setChosenCategory: (?ChosenCategory) => void,
|};
export const useFilters = (): FiltersState => {
const [chosenCategory, setChosenCategory] = React.useState<?ChosenCategory>(
null
);
const [chosenFilters, setChosenFilters] = React.useState<Set<string>>(
() => new Set()
);
const setChosenCategoryAndUpdateFilters = React.useCallback(
(newChosenCategory: ?ChosenCategory) => {
if (!newChosenCategory) {
// No more category is chosen. Keep the filters
setChosenCategory(null);
} else {
// Remove the filters that are not included in the category,
// as it would make no sense (not displayed, and everything would be filtered out).
const newChosenFilters = new Set();
for (const tag of chosenFilters.keys()) {
if (newChosenCategory.node.allChildrenTags.includes(tag)) {
newChosenFilters.add(tag);
}
}
setChosenFilters(newChosenFilters);
setChosenCategory(newChosenCategory);
}
},
[chosenFilters]
);
return {
chosenCategory,
setChosenCategory: setChosenCategoryAndUpdateFilters,
chosenFilters,
addFilter: (tag: string) => {
const newChosenFilters = new Set(chosenFilters);
newChosenFilters.add(tag);
setChosenFilters(newChosenFilters);
},
removeFilter: (tag: string) => {
const newChosenFilters = new Set(chosenFilters);
newChosenFilters.delete(tag);
setChosenFilters(newChosenFilters);
},
};
};
const nodeIdSeparator = ';-/>'; // A seemingly random string to ensure no clashes with tag names.
const toNodeId = (nodes: Array<TagsTreeNode>) =>
nodes.map(node => node.name).join(nodeIdSeparator);
const TagsTreeItems = ({
tagsTreeNodes,
onChoose,
parentNodes,
}: {|
parentNodes: Array<TagsTreeNode>,
tagsTreeNodes: Array<TagsTreeNode>,
onChoose: ChosenCategory => void,
|}) => {
if (!tagsTreeNodes.length) return null;
return tagsTreeNodes.map(node => {
const newParentNodes = [...parentNodes, node];
return (
<TreeItem
nodeId={toNodeId(newParentNodes)}
label={node.name}
key={node.name}
onLabelClick={() => onChoose({ node, parentNodes })}
collapseIcon={node.children.length ? <ExpandMoreIcon /> : null}
expandIcon={node.children.length ? <ChevronRightIcon /> : null}
>
<TagsTreeItems
tagsTreeNodes={node.children}
parentNodes={newParentNodes}
onChoose={onChoose}
/>
</TreeItem>
);
});
};
const MemoizedTagsTree = React.memo(function TagsTree({
chosenCategory,
setChosenCategory,
allFilters,
}) {
return (
<TreeView
selected={
chosenCategory
? toNodeId([...chosenCategory.parentNodes, chosenCategory.node])
: ''
}
onNodeSelect={() => {}}
>
<TreeItem
nodeId=""
label={<Trans>All assets</Trans>}
onLabelClick={() => setChosenCategory(null)}
/>
<TagsTreeItems
tagsTreeNodes={allFilters.tagsTree}
onChoose={setChosenCategory}
parentNodes={[]}
/>
</TreeView>
);
});
type Props = {|
filtersState: FiltersState,
allFilters: ?Filters,
error: ?Error,
|};
export const FiltersChooser = ({ filtersState, allFilters, error }: Props) => {
if (!allFilters) {
return <PlaceholderLoader />;
}
if (error) {
// Error and retry button shown somewhere else in the UI
return null;
}
// Only display the tags that are contained inside the selected category
const selectedCategoryTags = filtersState.chosenCategory
? filtersState.chosenCategory.node.allChildrenTags
: allFilters.defaultTags;
return (
<React.Fragment>
<Subheader>Categories</Subheader>
<MemoizedTagsTree
chosenCategory={filtersState.chosenCategory}
setChosenCategory={filtersState.setChosenCategory}
allFilters={allFilters}
/>
<Subheader>Filters</Subheader>
<ColumnStackLayout>
{!selectedCategoryTags ? (
<EmptyMessage>
<Trans>Choose a category to display filters</Trans>
</EmptyMessage>
) : selectedCategoryTags.length ? (
selectedCategoryTags.map(tag => (
<InlineCheckbox
key={tag}
label={tag}
checked={filtersState.chosenFilters.has(tag)}
onCheck={() => {
if (filtersState.chosenFilters.has(tag)) {
filtersState.removeFilter(tag);
} else {
filtersState.addFilter(tag);
}
}}
/>
))
) : (
<EmptyMessage>
<Trans>No filters in this category.</Trans>
</EmptyMessage>
)}
</ColumnStackLayout>
</React.Fragment>
);
};

View File

@@ -0,0 +1,477 @@
// @flow
import {
type Asset,
type AssetShortHeader,
getAsset,
} from '../Utils/GDevelopServices/Asset';
import newNameGenerator from '../Utils/NewNameGenerator';
import { isNullPtr } from '../Utils/IsNullPtr';
import { unserializeFromJSObject } from '../Utils/Serializer';
import flatten from 'lodash/flatten';
import uniqBy from 'lodash/uniqBy';
import uniq from 'lodash/uniq';
import {
getExtensionsRegistry,
getExtension,
type SerializedExtension,
} from '../Utils/GDevelopServices/Extension';
import { type EventsFunctionsExtensionsState } from '../EventsFunctionsExtensionsLoader/EventsFunctionsExtensionsContext';
import { mapVector } from '../Utils/MapFor';
const gd: libGDevelop = global.gd;
const toNewGdMapStringString = (object: {
[string]: string,
}): gdMapStringString => {
const map = new gd.MapStringString();
for (var key in object) {
map.set(key, object[key]);
}
return map;
};
const toPascalCase = (str: string) => {
if (!str) return '';
return str
.replace(/^[^A-Za-z0-9]*|[^A-Za-z0-9]*$/g, '$')
.replace(/[^A-Za-z0-9]+/g, '$')
.replace(/([a-z])([A-Z])/g, function(m, a, b) {
return a + '$' + b;
})
.toLowerCase()
.replace(/(\$)(\w?)/g, function(m, a, b) {
return b.toUpperCase();
});
};
export const sanitizeObjectName = (objectName: string) => {
const trimmedObjectName = objectName.trim();
if (!trimmedObjectName) return 'UnnamedObject';
const pascalCaseName = toPascalCase(trimmedObjectName);
let prefixedObjectName = pascalCaseName;
if (prefixedObjectName[0] >= '0' && prefixedObjectName[0] <= '9') {
prefixedObjectName = '_' + prefixedObjectName;
}
return prefixedObjectName;
};
export const installResource = (
resourcesManager: gdResourcesManager,
serializedResource: any,
resourceNewNames: { [string]: string }
) => {
const makeGdResource = () => {
let resource;
if (serializedResource.kind === 'image') {
resource = new gd.ImageResource();
} else {
throw new Error(
`Resource of kind "${serializedResource.kind}" is not supported.`
);
}
unserializeFromJSObject(resource, serializedResource);
return resource;
};
const originalResourceName: string = serializedResource.name;
const resourceFileUrl: string = serializedResource.file;
if (resourceNewNames[originalResourceName]) {
// The resource was already added previously - don't
// bother adding it again.
return;
}
if (!resourcesManager.hasResource(originalResourceName)) {
// There is no existing resource with this name, just add the new resource.
const newResource = makeGdResource();
newResource.setName(originalResourceName);
resourceNewNames[originalResourceName] = originalResourceName;
resourcesManager.addResource(newResource);
newResource.delete();
return;
}
// There is an existing resource with the name - check if it's the
// same resource or not.
const existingResource = resourcesManager.getResource(originalResourceName);
const file = existingResource.getFile();
if (file === resourceFileUrl) {
// Resource is already existing and is the same - nothing to do.
return;
}
// The existing resource is different - find a new name for the new resource.
const newName = newNameGenerator(originalResourceName, name =>
resourcesManager.hasResource(name)
);
const newResource = makeGdResource();
newResource.setName(newName);
resourceNewNames[originalResourceName] = newName;
resourcesManager.addResource(newResource);
newResource.delete();
};
export const addAssetToProject = async ({
asset,
project,
events,
objectsContainer,
}: {|
asset: Asset,
project: gdProject,
events: gdEventsList,
objectsContainer: gdObjectsContainer,
|}) => {
const objectNewNames = {};
const resourceNewNames = {};
const createdObjects: Array<gdObject> = [];
asset.objectAssets.forEach(objectAsset => {
const type: ?string = objectAsset.object.type;
if (!type) throw new Error('An object has no type specified');
// Insert the object
const originalName = sanitizeObjectName(objectAsset.object.name);
const newName = newNameGenerator(originalName, name =>
objectsContainer.hasObjectNamed(name)
);
const object = objectsContainer.insertNewObject(
project,
type,
newName,
objectsContainer.getObjectsCount()
);
objectNewNames[originalName] = newName;
unserializeFromJSObject(
object,
objectAsset.object,
'unserializeFrom',
project
);
// The name was overwritten after unserialization.
object.setName(newName);
// Add resources used by the object
objectAsset.resources.forEach(serializedResource => {
installResource(
project.getResourcesManager(),
serializedResource,
resourceNewNames
);
});
// TODO: Check how multiple objects are handled
// Resources may have been renamed to be added to the project.
// In this case, rename them in the object.
const renamedResourcesMap = toNewGdMapStringString(resourceNewNames);
const resourcesRenamer = new gd.ResourcesRenamer(renamedResourcesMap);
renamedResourcesMap.delete();
object.exposeResources(resourcesRenamer);
resourcesRenamer.delete();
objectAsset.customization.forEach(customization => {
if (customization.behaviorName) {
const { behaviorName, behaviorType } = customization;
const behavior = gd.JsPlatform.get().getBehavior(behaviorType);
if (isNullPtr(gd, behavior)) {
throw new Error(
'Behavior with type ' + behaviorType + ' could not be found.'
);
}
const behaviorContent = object.addNewBehavior(
project,
behaviorType,
behaviorName
);
customization.properties.forEach(property => {
behavior.updateProperty(
behaviorContent.getContent(),
property.name,
property.defaultValue
);
});
}
});
createdObjects.push(object);
});
// Add the events after adding all objects, as we need to potentially
// rename the objects in the inserted events.
asset.objectAssets.forEach(objectAsset => {
const originalName = objectAsset.object.name;
const newName = objectNewNames[originalName];
objectAsset.customization.forEach(customization => {
if (customization.events) {
const groupEvent = new gd.GroupEvent();
groupEvent.setName(newName);
unserializeFromJSObject(
groupEvent.getSubEvents(),
customization.events,
'unserializeFrom',
project
);
// Find/replace the customization parameters in the events.
customization.parameters.forEach(parameter => {
gd.EventsRefactorer.replaceStringInEvents(
project,
objectsContainer,
groupEvent.getSubEvents(),
parameter.name,
parameter.defaultValue,
true,
true,
true
);
});
// Rename any object that was renamed when inserted.
// Do this **after** replacing the customization parameters,
// as some expressions can be invalid before customization
// parameters replacements.
for (const originalName in objectNewNames) {
const newName = objectNewNames[originalName];
if (originalName !== newName) {
gd.EventsRefactorer.renameObjectInEvents(
project.getCurrentPlatform(),
project,
objectsContainer,
groupEvent.getSubEvents(),
originalName,
newName
);
}
}
events.insertEvent(groupEvent, events.getEventsCount());
}
});
});
return {
createdObjects,
}
};
type RequiredBehavior = {|
extensionName: string,
extensionVersion: string,
behaviorType: string,
|};
export const getRequiredBehaviorsFromAsset = (
asset: Asset
): Array<RequiredBehavior> => {
return uniqBy(
flatten(
asset.objectAssets.map(objectAsset => {
return objectAsset.customization
.map(customization => {
if (customization.behaviorName) {
const {
behaviorType,
extensionName,
extensionVersion,
} = customization;
return { behaviorType, extensionName, extensionVersion };
}
return null;
})
.filter(Boolean);
})
),
({ behaviorType }) => behaviorType // TODO: extension name??
);
};
type RequiredExtension = {|
extensionName: string,
extensionVersion: string,
|};
export const filterMissingExtensions = (
gd: libGDevelop,
requiredExtensions: Array<RequiredExtension>
): Array<RequiredExtension> => {
const loadedExtensionNames = mapVector(
gd.asPlatform(gd.JsPlatform.get()).getAllPlatformExtensions(),
extension => {
return extension.getName();
}
);
return requiredExtensions.filter(({ extensionName }) => {
return !loadedExtensionNames.includes(extensionName);
});
};
export const getRequiredExtensionsForEventsFromAsset = (
asset: Asset
): Array<RequiredExtension> => {
return uniqBy(
flatten(
asset.objectAssets.map(objectAsset => {
return flatten(
objectAsset.customization
.map(customization => {
if (customization.events) {
return customization.extensions;
}
return null;
})
.filter(Boolean)
);
})
),
({ extensionName }) => extensionName
);
};
export const filterMissingBehaviors = (
gd: libGDevelop,
requiredBehaviors: Array<RequiredBehavior>
): Array<RequiredBehavior> => {
return requiredBehaviors.filter(({ behaviorType }) => {
const behavior = gd.JsPlatform.get().getBehavior(behaviorType);
return isNullPtr(gd, behavior);
});
};
export const downloadExtensions = async (
extensionNames: Array<string>
): Promise<Array<SerializedExtension>> => {
if (!extensionNames.length) return Promise.resolve([]);
const extensionsRegistry = await getExtensionsRegistry();
const serializedExtensions = await Promise.all(
uniq(extensionNames).map(extensionName => {
const extensionShortHeader = extensionsRegistry.extensionShortHeaders.find(
extensionShortHeader => {
return extensionShortHeader.name === extensionName;
}
);
if (!extensionShortHeader) {
throw new Error(
'Unable to find extension ' + extensionName + ' in the registry.'
);
}
return getExtension(extensionShortHeader);
})
);
return serializedExtensions;
};
/**
* Add a serialized (JS object) events function extension to the project,
* triggering reload of extensions.
*/
export const addSerializedExtensionsToProject = (
eventsFunctionsExtensionsState: EventsFunctionsExtensionsState,
project: gdProject,
serializedExtensions: Array<SerializedExtension>
): Promise<void> => {
serializedExtensions.forEach(serializedExtension => {
const { name } = serializedExtension;
if (!name)
return Promise.reject(new Error('Malformed extension (missing name).'));
const newEventsFunctionsExtension = project.hasEventsFunctionsExtensionNamed(
name
)
? project.getEventsFunctionsExtension(name)
: project.insertNewEventsFunctionsExtension(name, 0);
unserializeFromJSObject(
newEventsFunctionsExtension,
serializedExtension,
'unserializeFrom',
project
);
});
return eventsFunctionsExtensionsState.loadProjectEventsFunctionsExtensions(
project
);
};
type InstallAssetArgs = {|
assetShortHeader: AssetShortHeader,
eventsFunctionsExtensionsState: EventsFunctionsExtensionsState,
project: gdProject,
events: gdEventsList,
objectsContainer: gdObjectsContainer,
|};
type InstallAssetOutput = {|
createdObjects: Array<gdObject>,
|};
export const installAsset = async ({
assetShortHeader,
eventsFunctionsExtensionsState,
project,
events,
objectsContainer,
}: InstallAssetArgs): Promise<InstallAssetOutput> => {
const asset = await getAsset(assetShortHeader);
const requiredBehaviors = getRequiredBehaviorsFromAsset(asset);
const requiredExtensions = getRequiredExtensionsForEventsFromAsset(asset);
const missingBehaviors = filterMissingBehaviors(gd, requiredBehaviors);
const missingExtensions = filterMissingExtensions(gd, requiredExtensions);
const serializedExtensions = await downloadExtensions([
...missingBehaviors.map(({ extensionName }) => extensionName),
...missingExtensions.map(({ extensionName }) => extensionName),
]);
await addSerializedExtensionsToProject(
eventsFunctionsExtensionsState,
project,
serializedExtensions
);
const stillMissingBehaviors = filterMissingBehaviors(gd, requiredBehaviors);
if (stillMissingBehaviors.length) {
throw new Error(
'These behaviors could not be installed: ' +
missingBehaviors
.map(
({ extensionName, behaviorType }) =>
`${behaviorType} (${extensionName})`
)
.join(', ')
);
}
const stillMissingExtensions = filterMissingExtensions(
gd,
requiredExtensions
);
if (stillMissingExtensions.length) {
throw new Error(
'These extensions could not be installed: ' +
missingExtensions.map(({ extensionName }) => extensionName).join(', ')
);
}
const output = await addAssetToProject({ project, asset, events, objectsContainer });
return output;
};

View File

@@ -0,0 +1,683 @@
// @flow
import {
addAssetToProject,
getRequiredBehaviorsFromAsset,
filterMissingBehaviors,
downloadExtensions,
installAsset,
getRequiredExtensionsForEventsFromAsset,
filterMissingExtensions,
sanitizeObjectName,
} from './InstallAsset';
import { makeTestProject } from '../fixtures/TestProject';
import { type EventsFunctionsExtensionsState } from '../EventsFunctionsExtensionsLoader/EventsFunctionsExtensionsContext';
import {
fakeAssetShortHeader1,
fakeAsset1,
fakeAssetWithBehaviorCustomizations1,
fakeAssetWithUnknownBehaviorCustomizations1,
fakeAssetWithFlashBehaviorCustomizations1,
fakeAssetWithEventCustomizationsAndFlashExtension1,
flashExtensionShortHeader,
fireBulletExtensionShortHeader,
fakeAssetWithEventCustomizationsAndUnknownExtension1,
} from '../fixtures/GDevelopServicesTestData';
import { makeTestExtensions } from '../fixtures/TestExtensions';
import {
getExtensionsRegistry,
getExtension,
} from '../Utils/GDevelopServices/Extension';
import { jssPreset } from '@material-ui/core';
import { getAsset } from '../Utils/GDevelopServices/Asset';
const gd: libGDevelop = global.gd;
jest.mock('../Utils/GDevelopServices/Extension');
jest.mock('../Utils/GDevelopServices/Asset');
const mockFn = (fn: Function): JestMockFn<any, any> => fn;
describe('InstallAsset', () => {
describe('sanitizeObjectName', () => {
expect(sanitizeObjectName('')).toBe('UnnamedObject');
expect(sanitizeObjectName('HelloWorld')).toBe('HelloWorld');
expect(sanitizeObjectName('Hello World')).toBe('HelloWorld');
expect(sanitizeObjectName('hello world')).toBe('HelloWorld');
expect(sanitizeObjectName('hello world12')).toBe('HelloWorld12');
expect(sanitizeObjectName('12 hello world')).toBe('_12HelloWorld');
expect(sanitizeObjectName('/-=hello/-=world/-=')).toBe('HelloWorld');
expect(sanitizeObjectName(' hello/-=world/-=')).toBe('HelloWorld');
expect(sanitizeObjectName('9hello/-=world/-=')).toBe('_9helloWorld');
expect(sanitizeObjectName(' 9hello/-=world/-=')).toBe('_9helloWorld');
});
describe('addAssetToProject', () => {
it('installs an object asset in the project, without renaming it if not needed', async () => {
const { project } = makeTestProject(gd);
const layout = project.insertNewLayout('MyTestLayout', 0);
const output = await addAssetToProject({
project,
objectsContainer: layout,
events: layout.getEvents(),
asset: fakeAsset1,
});
expect(output.createdObjects).toHaveLength(1);
expect(layout.hasObjectNamed('PlayerSpaceship')).toBe(true);
expect(output.createdObjects).toEqual([
layout.getObject('PlayerSpaceship'),
]);
expect(
project.getResourcesManager().hasResource('player-ship1.png')
).toBe(true);
expect(
project.getResourcesManager().hasResource('player-ship2.png')
).toBe(true);
});
it('renames the object if name is already used', async () => {
const { project } = makeTestProject(gd);
const layout = project.insertNewLayout('MyTestLayout', 0);
layout.insertNewObject(project, 'Sprite', 'PlayerSpaceship', 0);
const output = await addAssetToProject({
project,
objectsContainer: layout,
events: layout.getEvents(),
asset: fakeAsset1,
});
expect(output.createdObjects).toHaveLength(1);
expect(layout.hasObjectNamed('PlayerSpaceship')).toBe(true);
expect(layout.hasObjectNamed('PlayerSpaceship2')).toBe(true);
expect(
project.getResourcesManager().hasResource('player-ship1.png')
).toBe(true);
expect(
project.getResourcesManager().hasResource('player-ship2.png')
).toBe(true);
});
it('does not add a resource if it is already existing', async () => {
const { project } = makeTestProject(gd);
const layout = project.insertNewLayout('MyTestLayout', 0);
const originalResourceNames = project
.getResourcesManager()
.getAllResourceNames()
.toJSArray();
// Create a resource that is the same as the one added for the spaceship
const resource = new gd.ImageResource();
resource.setName('player-ship1.png');
resource.setFile('https://example.com/player-ship1.png');
project.getResourcesManager().addResource(resource);
resource.delete();
// Install the spaceship
await addAssetToProject({
project,
objectsContainer: layout,
events: layout.getEvents(),
asset: fakeAsset1,
});
// Verify there was not extra resource added.
expect(
project
.getResourcesManager()
.getAllResourceNames()
.toJSArray()
).toEqual([
...originalResourceNames,
'player-ship1.png',
'player-ship2.png',
]);
});
it('add a resource with a new name, if this name is already taken by another', async () => {
const { project } = makeTestProject(gd);
const layout = project.insertNewLayout('MyTestLayout', 0);
const originalResourceNames = project
.getResourcesManager()
.getAllResourceNames()
.toJSArray();
// Create a resource that is NOT the same as the one added for the spaceship
// but has the same name.
const resource = new gd.ImageResource();
resource.setName('player-ship1.png');
resource.setFile('https://example.com/some-unrelated-file.png');
project.getResourcesManager().addResource(resource);
resource.delete();
// Install the spaceship
await addAssetToProject({
project,
objectsContainer: layout,
events: layout.getEvents(),
asset: fakeAsset1,
});
// Verify there was not extra resource added
expect(
project
.getResourcesManager()
.getAllResourceNames()
.toJSArray()
).toEqual([
...originalResourceNames,
'player-ship1.png',
'player-ship1.png2',
'player-ship2.png',
]);
expect(
project
.getResourcesManager()
.getResource('player-ship1.png2')
.getFile()
).toBe('https://example.com/player-ship1.png');
// Verify the resource names used by the object
expect(layout.hasObjectNamed('PlayerSpaceship')).toBe(true);
const object = layout.getObject('PlayerSpaceship');
const resourcesInUse = new gd.ResourcesInUseHelper();
object.exposeResources(resourcesInUse);
const objectResourceNames = resourcesInUse
.getAllImages()
.toNewVectorString()
.toJSArray();
resourcesInUse.delete();
expect(objectResourceNames).toEqual([
'player-ship1.png2',
'player-ship2.png',
]);
});
it('installs an object asset in the project, adding the required behaviors', async () => {
makeTestExtensions(gd);
const { project } = makeTestProject(gd);
const layout = project.insertNewLayout('MyTestLayout', 0);
await addAssetToProject({
project,
objectsContainer: layout,
events: layout.getEvents(),
asset: fakeAssetWithBehaviorCustomizations1,
});
expect(layout.hasObjectNamed('PlayerSpaceship')).toBe(true);
expect(
layout
.getObject('PlayerSpaceship')
.getAllBehaviorNames()
.toJSArray()
).toEqual(['MyBehavior']);
expect(
layout
.getObject('PlayerSpaceship')
.getBehavior('MyBehavior')
.getTypeName()
).toBe('FakeBehavior::FakeBehavior');
// Check that the properties from customization were set.
expect(
gd.Serializer.toJSON(
layout
.getObject('PlayerSpaceship')
.getBehavior('MyBehavior')
.getContent()
)
).toBe('{"property1": "Overriden value","property2": true}');
});
it('installs an object asset in the project, adding the required events', async () => {
makeTestExtensions(gd);
const { project } = makeTestProject(gd);
const layout = project.insertNewLayout('MyTestLayout', 0);
const eventsList = new gd.EventsList();
await addAssetToProject({
project,
objectsContainer: layout,
events: eventsList,
asset: fakeAssetWithEventCustomizationsAndFlashExtension1,
});
expect(layout.hasObjectNamed('PlayerSpaceship')).toBe(true);
expect(eventsList.getEventsCount()).toBe(1);
// Check that the events had their customization parameters properly applied
const serializedEvents = new gd.SerializerElement();
eventsList.serializeTo(serializedEvents);
const serializedEventsJson = gd.Serializer.toJSON(serializedEvents);
expect(serializedEventsJson).toMatch(
'3 + PlayerSpaceship.Variable(test)'
);
expect(serializedEventsJson).toMatch(
'3 + PlayerSpaceship.Variable(test2)'
);
});
it('renames the object if name is already used, including in events', async () => {
makeTestExtensions(gd);
const { project } = makeTestProject(gd);
const layout = project.insertNewLayout('MyTestLayout', 0);
const eventsList = new gd.EventsList();
// Add an object with the same name as the object asset.
layout.insertNewObject(project, 'Sprite', 'PlayerSpaceship', 0);
expect(layout.hasObjectNamed('PlayerSpaceship')).toBe(true);
await addAssetToProject({
project,
objectsContainer: layout,
events: eventsList,
asset: fakeAssetWithEventCustomizationsAndFlashExtension1,
});
expect(layout.hasObjectNamed('PlayerSpaceship2')).toBe(true);
expect(eventsList.getEventsCount()).toBe(1);
// Check that the events had their customization parameters properly applied
// and the object renamed.
const serializedEvents = new gd.SerializerElement();
eventsList.serializeTo(serializedEvents);
const serializedEventsJson = gd.Serializer.toJSON(serializedEvents);
expect(serializedEventsJson).toMatch(
'3 + PlayerSpaceship2.Variable(test)'
);
expect(serializedEventsJson).toMatch(
'3 + PlayerSpaceship2.Variable(test2)'
);
});
});
describe('getRequiredBehaviorsFromAsset', () => {
it('get the behaviors required for an asset', () => {
expect(
getRequiredBehaviorsFromAsset(
fakeAssetWithEventCustomizationsAndFlashExtension1
)
).toEqual([]);
expect(
getRequiredBehaviorsFromAsset(fakeAssetWithBehaviorCustomizations1)
).toEqual([
{
behaviorType: 'FakeBehavior::FakeBehavior',
extensionName: 'FakeBehavior',
extensionVersion: '1.0.0',
},
]);
});
});
describe('getRequiredExtensionsForEventsFromAsset', () => {
it('get the extensions required for an asset', () => {
expect(
getRequiredExtensionsForEventsFromAsset(
fakeAssetWithBehaviorCustomizations1
)
).toEqual([]);
expect(
getRequiredExtensionsForEventsFromAsset(
fakeAssetWithEventCustomizationsAndFlashExtension1
)
).toEqual([
{
extensionName: 'Flash',
extensionVersion: '1.0.0',
},
]);
});
});
describe('filterMissingBehaviors', () => {
it('filters behaviors that are not loaded ', () => {
makeTestExtensions(gd);
expect(
filterMissingBehaviors(gd, [
// An unknown behavior not loaded:
{
extensionName: 'NotExistingExtension',
extensionVersion: '1.0.0',
behaviorType: 'NotExistingExtension::MissingBehavior',
},
// A fake behavior loaded in makeTestExtensions:
{
behaviorType: 'FakeBehavior::FakeBehavior',
extensionName: 'FakeBehavior',
extensionVersion: '1.0.0',
},
])
).toEqual([
{
extensionName: 'NotExistingExtension',
extensionVersion: '1.0.0',
behaviorType: 'NotExistingExtension::MissingBehavior',
},
]);
});
});
describe('filterMissingExtensions', () => {
it('filters extensions that are not loaded ', () => {
makeTestExtensions(gd);
expect(
filterMissingExtensions(gd, [
// An unknown behavior not loaded:
{
extensionName: 'NotExistingExtension',
extensionVersion: '1.0.0',
},
// A fake extension loaded in makeTestExtensions:
{
extensionName: 'FakeBehavior',
extensionVersion: '1.0.0',
},
])
).toEqual([
{
extensionName: 'NotExistingExtension',
extensionVersion: '1.0.0',
},
]);
});
});
describe('downloadExtensions', () => {
it('loads the required extensions ', async () => {
mockFn(getExtensionsRegistry).mockImplementationOnce(() => ({
version: '1.0.0',
allTags: [''],
extensionShortHeaders: [
flashExtensionShortHeader,
fireBulletExtensionShortHeader,
],
}));
mockFn(getExtension).mockImplementationOnce(
() => fireBulletExtensionShortHeader
);
await expect(downloadExtensions(['FireBullet'])).resolves.toEqual([
fireBulletExtensionShortHeader,
]);
});
it('errors if an extension is not found ', async () => {
mockFn(getExtensionsRegistry).mockImplementationOnce(() => ({
version: '1.0.0',
allTags: [''],
extensionShortHeaders: [
flashExtensionShortHeader,
fireBulletExtensionShortHeader,
],
}));
await expect(
downloadExtensions(['NotFoundExtension'])
).rejects.toMatchObject({
message: 'Unable to find extension NotFoundExtension in the registry.',
});
});
it("errors if the registry can't be loaded ", async () => {
mockFn(getExtensionsRegistry).mockImplementationOnce(() => {
throw new Error('Fake error');
});
await expect(downloadExtensions(['FakeExtension'])).rejects.toMatchObject(
{ message: 'Fake error' }
);
});
});
describe('installAsset', () => {
beforeEach(() => {
mockFn(getAsset).mockReset();
mockFn(getExtensionsRegistry).mockReset();
mockFn(getExtension).mockReset();
});
const mockEventsFunctionsExtensionsState: EventsFunctionsExtensionsState = {
eventsFunctionsExtensionsError: null,
loadProjectEventsFunctionsExtensions: () => Promise.resolve(),
unloadProjectEventsFunctionsExtensions: () => {},
reloadProjectEventsFunctionsExtensions: () => Promise.resolve(),
unloadProjectEventsFunctionsExtension: () => {},
getEventsFunctionsExtensionWriter: () => null,
getEventsFunctionsExtensionOpener: () => null,
ensureLoadFinished: () => Promise.resolve(),
getIncludeFileHashs: () => ({}),
};
it("throws if asset can't be downloaded", async () => {
makeTestExtensions(gd);
const { project } = makeTestProject(gd);
const layout = project.insertNewLayout('MyTestLayout', 0);
const eventsList = new gd.EventsList();
mockFn(getAsset).mockImplementationOnce(() => {
throw new Error('Fake error - unable to download');
});
await expect(
installAsset({
assetShortHeader: fakeAssetShortHeader1,
events: eventsList,
project,
objectsContainer: layout,
eventsFunctionsExtensionsState: mockEventsFunctionsExtensionsState,
})
).rejects.toMatchObject({
message: 'Fake error - unable to download',
});
expect(getExtensionsRegistry).not.toHaveBeenCalled();
expect(getExtension).not.toHaveBeenCalled();
});
it("throws if an extension for a behavior can't be found in the registry", async () => {
makeTestExtensions(gd);
const { project } = makeTestProject(gd);
const layout = project.insertNewLayout('MyTestLayout', 0);
const eventsList = new gd.EventsList();
// Get an asset that uses a behavior...
mockFn(getAsset).mockImplementationOnce(
() => fakeAssetWithUnknownBehaviorCustomizations1
);
// ...but this behavior extension does not exist in the registry
mockFn(getExtensionsRegistry).mockImplementationOnce(() => ({
version: '1.0.0',
allTags: [''],
extensionShortHeaders: [
flashExtensionShortHeader,
fireBulletExtensionShortHeader,
],
}));
// Check that the extension is stated as not found in the registry
await expect(
installAsset({
assetShortHeader: fakeAssetShortHeader1,
events: eventsList,
project,
objectsContainer: layout,
eventsFunctionsExtensionsState: mockEventsFunctionsExtensionsState,
})
).rejects.toMatchObject({
message: 'Unable to find extension UnknownBehavior in the registry.',
});
expect(getExtensionsRegistry).toHaveBeenCalledTimes(1);
expect(getExtension).not.toHaveBeenCalled();
});
it("throws if an extension can't be found in the registry", async () => {
makeTestExtensions(gd);
const { project } = makeTestProject(gd);
const layout = project.insertNewLayout('MyTestLayout', 0);
const eventsList = new gd.EventsList();
// Get an asset that uses an extension...
mockFn(getAsset).mockImplementationOnce(
() => fakeAssetWithEventCustomizationsAndUnknownExtension1
);
// ...but this extension does not exist in the registry
mockFn(getExtensionsRegistry).mockImplementationOnce(() => ({
version: '1.0.0',
allTags: [''],
extensionShortHeaders: [
flashExtensionShortHeader,
fireBulletExtensionShortHeader,
],
}));
// Check that the extension is stated as not found in the registry
await expect(
installAsset({
assetShortHeader: fakeAssetShortHeader1,
events: eventsList,
project,
objectsContainer: layout,
eventsFunctionsExtensionsState: mockEventsFunctionsExtensionsState,
})
).rejects.toMatchObject({
message: 'Unable to find extension UnknownExtension in the registry.',
});
expect(getExtensionsRegistry).toHaveBeenCalledTimes(1);
expect(getExtension).not.toHaveBeenCalled();
});
it("throws if a behavior can't be installed, even if its extension was properly found in the registry", async () => {
makeTestExtensions(gd);
const { project } = makeTestProject(gd);
const layout = project.insertNewLayout('MyTestLayout', 0);
const eventsList = new gd.EventsList();
// Get an asset that uses a behavior...
mockFn(getAsset).mockImplementationOnce(
() => fakeAssetWithFlashBehaviorCustomizations1
);
// ...and this behavior extension is in the registry
mockFn(getExtensionsRegistry).mockImplementationOnce(() => ({
version: '1.0.0',
allTags: [''],
extensionShortHeaders: [
flashExtensionShortHeader,
fireBulletExtensionShortHeader,
],
}));
mockFn(getExtension).mockImplementationOnce(
() => flashExtensionShortHeader
);
// Verify that, because we use `mockEventsFunctionsExtensionsState`, the
// extension won't be loaded, so the behavior won't be installed.
await expect(
installAsset({
assetShortHeader: fakeAssetShortHeader1,
events: eventsList,
project,
objectsContainer: layout,
eventsFunctionsExtensionsState: mockEventsFunctionsExtensionsState,
})
).rejects.toMatchObject({
message: 'These behaviors could not be installed: Flash::Flash (Flash)',
});
expect(getExtensionsRegistry).toHaveBeenCalledTimes(1);
expect(getExtension).toHaveBeenCalledTimes(1);
});
it("throws if an extension can't be installed, even if it was properly found in the registry", async () => {
makeTestExtensions(gd);
const { project } = makeTestProject(gd);
const layout = project.insertNewLayout('MyTestLayout', 0);
const eventsList = new gd.EventsList();
// Get an asset that uses an extension...
mockFn(getAsset).mockImplementationOnce(
() => fakeAssetWithEventCustomizationsAndFlashExtension1
);
// ...and this extension is in the registry
mockFn(getExtensionsRegistry).mockImplementationOnce(() => ({
version: '1.0.0',
allTags: [''],
extensionShortHeaders: [
flashExtensionShortHeader,
fireBulletExtensionShortHeader,
],
}));
mockFn(getExtension).mockImplementationOnce(
() => flashExtensionShortHeader
);
// Verify that, because we use `mockEventsFunctionsExtensionsState`, the
// extension won't be loaded, so the extension won't be installed.
await expect(
installAsset({
assetShortHeader: fakeAssetShortHeader1,
events: eventsList,
project,
objectsContainer: layout,
eventsFunctionsExtensionsState: mockEventsFunctionsExtensionsState,
})
).rejects.toMatchObject({
message: 'These extensions could not be installed: Flash',
});
expect(getExtensionsRegistry).toHaveBeenCalledTimes(1);
expect(getExtension).toHaveBeenCalledTimes(1);
});
it('install an asset, with a behavior that is already installed', async () => {
makeTestExtensions(gd);
const { project } = makeTestProject(gd);
const layout = project.insertNewLayout('MyTestLayout', 0);
const eventsList = new gd.EventsList();
// Fake an asset with a behavior of type "FakeBehavior::FakeBehavior",
// that is installed already.
mockFn(getAsset).mockImplementationOnce(
() => fakeAssetWithBehaviorCustomizations1
);
// Install the asset
await installAsset({
assetShortHeader: fakeAssetShortHeader1,
events: eventsList,
project,
objectsContainer: layout,
eventsFunctionsExtensionsState: mockEventsFunctionsExtensionsState,
});
// No extensions fetched because the behavior is already installed.
expect(getExtension).not.toHaveBeenCalled();
expect(getExtensionsRegistry).not.toHaveBeenCalled();
// Check that the object was created, with the proper behavior:
expect(layout.getObjectsCount()).toBe(1);
expect(layout.getObjectAt(0).getName()).toBe('PlayerSpaceship');
expect(
layout
.getObjectAt(0)
.getAllBehaviorNames()
.toJSArray()
).toEqual(['MyBehavior']);
});
});
});

View File

@@ -0,0 +1,260 @@
// @flow
import { Trans } from '@lingui/macro';
import * as React from 'react';
import Dialog from '../UI/Dialog';
import FlatButton from '../UI/FlatButton';
import ListIcon from '../UI/ListIcon';
import Subheader from '../UI/Subheader';
import { List, ListItem } from '../UI/List';
import Visibility from '@material-ui/icons/Visibility';
import VisibilityOff from '@material-ui/icons/VisibilityOff';
import {
enumerateObjectTypes,
type EnumeratedObjectMetadata,
} from '../ObjectsList/EnumerateObjects';
import HelpButton from '../UI/HelpButton';
import { getExperimentalObjects } from '../Hints';
import { Line, Column } from '../UI/Grid';
import { Tabs, Tab } from '../UI/Tabs';
import { AssetStore } from '.';
import { type AssetShortHeader } from '../Utils/GDevelopServices/Asset';
import EventsFunctionsExtensionsContext from '../EventsFunctionsExtensionsLoader/EventsFunctionsExtensionsContext';
import { installAsset } from './InstallAsset';
import { AssetDetails } from './AssetDetails';
import {
type ResourceSource,
type ChooseResourceFunction,
} from '../ResourcesList/ResourceSource.flow';
import { type ResourceExternalEditor } from '../ResourcesList/ResourceExternalEditor.flow';
import {
sendAssetAddedToProject,
sendAssetOpened,
} from '../Utils/Analytics/EventSender';
import optionalRequire from '../Utils/OptionalRequire';
const electron = optionalRequire('electron');
const ObjectListItem = ({
objectMetadata,
onClick,
}: {|
objectMetadata: EnumeratedObjectMetadata,
onClick: () => void,
|}) => {
if (objectMetadata.name === '') {
// Base object is an "abstract" object
return null;
}
return (
<ListItem
leftIcon={
<ListIcon
src={objectMetadata.iconFilename}
iconSize={40}
isGDevelopIcon
/>
}
key={objectMetadata.name}
primaryText={objectMetadata.fullName}
secondaryText={objectMetadata.description}
secondaryTextLines={2}
onClick={onClick}
/>
);
};
type Props = {|
project: gdProject,
layout: ?gdLayout,
objectsContainer: gdObjectsContainer,
resourceSources: Array<ResourceSource>,
onChooseResource: ChooseResourceFunction,
resourceExternalEditors: Array<ResourceExternalEditor>,
events: gdEventsList,
onClose: () => void,
onCreateNewObject: (type: string) => void,
onObjectAddedFromAsset: gdObject => void,
|};
export default function NewObjectDialog({
project,
layout,
objectsContainer,
resourceSources,
onChooseResource,
resourceExternalEditors,
events,
onClose,
onCreateNewObject,
onObjectAddedFromAsset,
}: Props) {
const [openedAsset, setOpenedAsset] = React.useState<null | AssetShortHeader>(
null
);
const showAssetStore = !electron;
const [currentTab, setCurrentTab] = React.useState(
showAssetStore ? 'asset-store' : 'new-object'
);
const [showExperimental, setShowExperimental] = React.useState(false);
const objectMetadata = React.useMemo(() => enumerateObjectTypes(project), [
project,
]);
const experimentalObjectsInformation = getExperimentalObjects();
const objects = objectMetadata.filter(
({ name }) => !experimentalObjectsInformation[name]
);
const experimentalObjects = objectMetadata.filter(
({ name }) => !!experimentalObjectsInformation[name]
);
const [
assetBeingInstalled,
setAssetBeingInstalled,
] = React.useState<?AssetShortHeader>(null);
const eventsFunctionsExtensionsState = React.useContext(
EventsFunctionsExtensionsContext
);
const onInstallAsset = React.useCallback(
(assetShortHeader: AssetShortHeader) => {
setAssetBeingInstalled(assetShortHeader);
(async () => {
try {
const installOutput = await installAsset({
assetShortHeader,
eventsFunctionsExtensionsState,
project,
objectsContainer,
events,
});
sendAssetAddedToProject({
id: assetShortHeader.id,
name: assetShortHeader.name,
});
console.log('Asset successfully installed.');
installOutput.createdObjects.forEach(object => {
onObjectAddedFromAsset(object);
});
} catch (error) {
console.error('Error while installing asset:', error);
//TODO: Handle error
}
setAssetBeingInstalled(null);
})();
},
[eventsFunctionsExtensionsState, project, objectsContainer, events]
);
return (
<Dialog
title={<Trans>Add a new object</Trans>}
secondaryActions={<HelpButton helpPagePath="/objects" />}
actions={[
<FlatButton
key="close"
label={<Trans>Close</Trans>}
primary={false}
onClick={onClose}
/>,
]}
onRequestClose={onClose}
cannotBeDismissed={false}
open
flexBody
noMargin
>
<Column noMargin expand>
{showAssetStore && (
<Tabs value={currentTab} onChange={setCurrentTab}>
<Tab
label={<Trans>Search pre-made objects</Trans>}
value="asset-store"
/>
<Tab
label={<Trans>New object from scratch</Trans>}
value="new-object"
/>
</Tabs>
)}
{currentTab === 'asset-store' && (
<AssetStore
project={project}
objectsContainer={objectsContainer}
events={events}
onOpenDetails={assetShortHeader => {
setOpenedAsset(assetShortHeader);
sendAssetOpened({
id: assetShortHeader.id,
name: assetShortHeader.name,
});
}}
/>
)}
{currentTab === 'new-object' && (
<React.Fragment>
<List>
{objects.map(objectMetadata => (
<ObjectListItem
key={objectMetadata.name}
objectMetadata={objectMetadata}
onClick={() => onCreateNewObject(objectMetadata.name)}
/>
))}
{showExperimental && (
<Subheader>
Experimental (make sure to read the documentation page)
</Subheader>
)}
{showExperimental &&
experimentalObjects.map(objectMetadata => (
<ObjectListItem
key={objectMetadata.name}
objectMetadata={objectMetadata}
onClick={() => onCreateNewObject(objectMetadata.name)}
/>
))}
</List>
<Line justifyContent="center" alignItems="center">
{!showExperimental ? (
<FlatButton
key="toggle-experimental"
icon={<Visibility />}
primary={false}
onClick={() => setShowExperimental(true)}
label={<Trans>Show experimental objects</Trans>}
/>
) : (
<FlatButton
key="toggle-experimental"
icon={<VisibilityOff />}
primary={false}
onClick={() => setShowExperimental(false)}
label={<Trans>Hide experimental objects</Trans>}
/>
)}
</Line>
</React.Fragment>
)}
</Column>
{openedAsset !== null ? (
<AssetDetails
project={project}
layout={layout}
objectsContainer={objectsContainer}
resourceSources={resourceSources}
onChooseResource={onChooseResource}
resourceExternalEditors={resourceExternalEditors}
assetShortHeader={openedAsset}
onAdd={() => onInstallAsset(openedAsset)}
onClose={() => setOpenedAsset(null)}
canInstall={!assetBeingInstalled}
isBeingInstalled={
!!assetBeingInstalled && assetBeingInstalled.id === openedAsset.id
}
/>
) : null}
</Dialog>
);
}

View File

@@ -0,0 +1,102 @@
// @flow
import { Trans } from '@lingui/macro';
import * as React from 'react';
import { AssetCard } from './AssetCard';
import { type AssetShortHeader } from '../Utils/GDevelopServices/Asset';
import PlaceholderLoader from '../UI/PlaceholderLoader';
import PlaceholderError from '../UI/PlaceholderError';
import { AutoSizer, Grid } from 'react-virtualized';
import EmptyMessage from '../UI/EmptyMessage';
type Props = {|
assetShortHeaders: ?Array<AssetShortHeader>,
error: ?Error,
onRetry: () => void,
onOpenDetails: AssetShortHeader => void,
|};
const styles = {
container: { flex: 1 },
grid: { overflowX: 'hidden' },
};
export const SearchResults = ({
assetShortHeaders,
error,
onRetry,
onOpenDetails,
}: Props) => {
if (!assetShortHeaders) {
if (!error) return <PlaceholderLoader />;
else {
return (
<PlaceholderError onRetry={onRetry}>
<Trans>
Can't load the results. Verify your internet connection or retry
later.
</Trans>
</PlaceholderError>
);
}
} else if (assetShortHeaders.length === 0) {
return (
<EmptyMessage>
<Trans>
No results returned for your search. Try something else, browse the
categories or create your object from scratch!
</Trans>
</EmptyMessage>
);
}
return (
<div style={styles.container}>
<AutoSizer>
{({ width, height }) => {
if (!width || !height) return null;
const baseSize = 128;
const columnCount = Math.floor((width - 5) / baseSize);
const columnWidth = Math.floor(width / columnCount);
const rowCount = Math.max(
1,
Math.ceil(assetShortHeaders.length / columnCount)
);
function cellRenderer({ columnIndex, key, rowIndex, style }) {
const indexInList = rowIndex * columnCount + columnIndex;
const assetShortHeader =
indexInList < assetShortHeaders.length
? assetShortHeaders[indexInList]
: null;
return (
<div key={key} style={style}>
{assetShortHeader ? (
<AssetCard
size={columnWidth}
onOpenDetails={() => onOpenDetails(assetShortHeader)}
assetShortHeader={assetShortHeader}
/>
) : null}
</div>
);
}
return (
<Grid
width={width}
height={height}
columnCount={columnCount}
columnWidth={columnWidth}
rowHeight={columnWidth}
rowCount={rowCount}
cellRenderer={cellRenderer}
style={styles.grid}
/>
);
}}
</AutoSizer>
</div>
);
};

View File

@@ -0,0 +1,99 @@
// @flow
import * as React from 'react';
import SearchBar from '../UI/SearchBar';
import { Column, Line } from '../UI/Grid';
import Background from '../UI/Background';
import ScrollView from '../UI/ScrollView';
import { type AssetShortHeader } from '../Utils/GDevelopServices/Asset';
import { SearchResults } from './SearchResults';
import { FiltersChooser } from './FiltersChooser';
import { AssetStoreContext } from './AssetStoreContext';
const styles = {
previewBackground: {
background: 'url("res/transparentback.png") repeat',
width: 150,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
},
chip: {
marginBottom: 2,
marginRight: 2,
},
previewImage: {
maxWidth: 150,
verticalAlign: 'middle',
pointerEvents: 'none',
},
content: {
flex: '1',
},
searchBar: {
// TODO: Can we put this in the search bar by default?
flexShrink: 0,
},
};
type Props = {
project: gdProject,
objectsContainer: gdObjectsContainer,
events: gdEventsList,
onOpenDetails: AssetShortHeader => void,
};
export const AssetStore = ({
project,
objectsContainer,
events,
onOpenDetails,
}: Props) => {
const {
filters,
searchResults,
error,
fetchAssetsAndFilters,
filtersState,
searchText,
setSearchText,
} = React.useContext(AssetStoreContext);
React.useEffect(
() => {
fetchAssetsAndFilters();
},
[fetchAssetsAndFilters]
);
return (
<Column expand noMargin useFullHeight>
<SearchBar
value={searchText}
onChange={setSearchText}
onRequestSearch={() => {}}
style={styles.searchBar}
/>
<Line
overflow={
'hidden' /* Somehow required on Chrome/Firefox to avoid children growing (but not on Safari) */
}
>
<Background noFullHeight noExpand width={250}>
<ScrollView>
<FiltersChooser
allFilters={filters}
filtersState={filtersState}
error={error}
/>
</ScrollView>
</Background>
<SearchResults
onOpenDetails={onOpenDetails}
onRetry={fetchAssetsAndFilters}
error={error}
assetShortHeaders={searchResults}
/>
</Line>
</Column>
);
};

View File

@@ -99,7 +99,7 @@ export default class DebuggerContent extends React.Component<Props, State> {
toolbarControls: [],
renderEditor: () => (
<Background>
<Column expand noMargin useMaxHeight>
<Column expand noMargin useFullHeight>
<Line justifyContent="center">
<RaisedButton
label={<Trans>Refresh</Trans>}

View File

@@ -147,7 +147,7 @@ export default class EventsFunctionConfigurationEditor extends React.Component<
} = this.props;
return (
<Column expand noMargin useMaxHeight>
<Column expand noMargin useFullHeight>
<Tabs value={this.state.currentTab} onChange={this._chooseTab}>
<Tab
label={<Trans>Configuration</Trans>}

View File

@@ -7,13 +7,14 @@ import React, { Component } from 'react';
import Dialog from '../UI/Dialog';
import FlatButton from '../UI/FlatButton';
import CloudDownload from '@material-ui/icons/CloudDownload';
import ExtensionsSearch, { addSerializedExtensionToProject } from '.';
import ExtensionsSearch from '.';
import EventsFunctionsExtensionsContext, {
type EventsFunctionsExtensionsState,
} from '../EventsFunctionsExtensionsLoader/EventsFunctionsExtensionsContext';
import HelpButton from '../UI/HelpButton';
import { showErrorBox } from '../UI/Messages/MessageBox';
import Window from '../Utils/Window';
import { addSerializedExtensionsToProject } from '../AssetStore/InstallAsset';
type Props = {|
project: gdProject,
@@ -47,10 +48,10 @@ const importExtension = (
if (!answer) return;
}
return addSerializedExtensionToProject(
return addSerializedExtensionsToProject(
eventsFunctionsExtensionsState,
project,
serializedExtension
[serializedExtension]
);
});
})

View File

@@ -9,13 +9,11 @@ import {
getExtensionsRegistry,
type ExtensionsRegistry,
type ExtensionShortHeader,
type SerializedExtension,
getExtension,
} from '../Utils/GDevelopServices/Extension';
import { List, ListItem } from '../UI/List';
import PlaceholderLoader from '../UI/PlaceholderLoader';
import ExtensionInstallDialog from './ExtensionInstallDialog';
import { unserializeFromJSObject } from '../Utils/Serializer';
import { showErrorBox } from '../UI/Messages/MessageBox';
import EventsFunctionsExtensionsContext, {
type EventsFunctionsExtensionsState,
@@ -23,37 +21,7 @@ import EventsFunctionsExtensionsContext, {
import PlaceholderError from '../UI/PlaceholderError';
import EmptyMessage from '../UI/EmptyMessage';
import SearchbarWithChips from '../UI/SearchbarWithChips';
/**
* Add a serialized (JS object) events function extension to the project,
* triggering reload of extensions.
*/
export const addSerializedExtensionToProject = (
eventsFunctionsExtensionsState: EventsFunctionsExtensionsState,
project: gdProject,
serializedExtension: SerializedExtension
): Promise<void> => {
const { name } = serializedExtension;
if (!name)
return Promise.reject(new Error('Malformed extension (missing name).'));
const newEventsFunctionsExtension = project.hasEventsFunctionsExtensionNamed(
name
)
? project.getEventsFunctionsExtension(name)
: project.insertNewEventsFunctionsExtension(name, 0);
unserializeFromJSObject(
newEventsFunctionsExtension,
serializedExtension,
'unserializeFrom',
project
);
return eventsFunctionsExtensionsState.loadProjectEventsFunctionsExtensions(
project
);
};
import { addSerializedExtensionsToProject } from '../AssetStore/InstallAsset';
type Props = {|
project: gdProject,
@@ -157,10 +125,10 @@ export default class ExtensionsSearch extends Component<Props, State> {
getExtension(extensionShortHeader)
.then(
serializedExtension => {
return addSerializedExtensionToProject(
return addSerializedExtensionsToProject(
eventsFunctionsExtensionsState,
project,
serializedExtension
[serializedExtension]
).then(() => {
this.setState(
{

View File

@@ -28,6 +28,7 @@ import { UnsavedChangesContextProvider } from './UnsavedChangesContext';
import { CommandsContextProvider } from '../CommandPalette/CommandsContext';
import { create } from 'jss';
import rtl from 'jss-rtl';
import { AssetStoreStateProvider } from '../AssetStore/AssetStoreContext';
// Add the rtl plugin to the JSS instance to support RTL languages in material-ui components.
const jss = create({
@@ -93,14 +94,16 @@ export default class Providers extends React.Component<Props, {||}> {
}
>
<CommandsContextProvider>
<EventsFunctionsExtensionsContext.Consumer>
{eventsFunctionsExtensionsState =>
children({
i18n,
eventsFunctionsExtensionsState,
})
}
</EventsFunctionsExtensionsContext.Consumer>
<AssetStoreStateProvider>
<EventsFunctionsExtensionsContext.Consumer>
{eventsFunctionsExtensionsState =>
children({
i18n,
eventsFunctionsExtensionsState,
})
}
</EventsFunctionsExtensionsContext.Consumer>
</AssetStoreStateProvider>
</CommandsContextProvider>
</EventsFunctionsExtensionsProvider>
)}

View File

@@ -1,163 +0,0 @@
// @flow
import { Trans } from '@lingui/macro';
import React, { Component } from 'react';
import Dialog from '../UI/Dialog';
import FlatButton from '../UI/FlatButton';
import ListIcon from '../UI/ListIcon';
import Subheader from '../UI/Subheader';
import { List, ListItem } from '../UI/List';
import Visibility from '@material-ui/icons/Visibility';
import VisibilityOff from '@material-ui/icons/VisibilityOff';
import {
enumerateObjectTypes,
type EnumeratedObjectMetadata,
} from './EnumerateObjects';
import HelpButton from '../UI/HelpButton';
import { getExperimentalObjects } from '../Hints';
import { Line } from '../UI/Grid';
const ObjectListItem = ({
objectMetadata,
onClick,
}: {|
objectMetadata: EnumeratedObjectMetadata,
onClick: () => void,
|}) => {
if (objectMetadata.name === '') {
// Base object is an "abstract" object
return null;
}
return (
<ListItem
leftIcon={
<ListIcon
src={objectMetadata.iconFilename}
iconSize={40}
isGDevelopIcon
/>
}
key={objectMetadata.name}
primaryText={objectMetadata.fullName}
secondaryText={objectMetadata.description}
secondaryTextLines={2}
onClick={onClick}
/>
);
};
type Props = {|
project: gdProject,
open: boolean,
onClose: () => void,
onChoose: string => void,
|};
type State = {|
objectMetadata: Array<EnumeratedObjectMetadata>,
showExperimental: boolean,
|};
export default class NewObjectDialog extends Component<Props, State> {
state = { ...this._loadFrom(this.props.project), showExperimental: false };
_loadFrom(project: gdProject) {
if (!project || !project.getCurrentPlatform()) {
return { objectMetadata: [] };
}
return {
objectMetadata: enumerateObjectTypes(project),
};
}
componentWillReceiveProps(newProps: Props) {
if (
(!this.props.open && newProps.open) ||
(newProps.open && this.props.project !== newProps.project)
) {
this.setState(this._loadFrom(newProps.project));
}
}
_showExperimental = (showExperimental: boolean = true) => {
this.setState({
showExperimental,
});
};
render() {
const { project, open, onClose, onChoose } = this.props;
const { objectMetadata, showExperimental } = this.state;
if (!open || !project) return null;
const experimentalObjectsInformation = getExperimentalObjects();
const objects = objectMetadata.filter(
({ name }) => !experimentalObjectsInformation[name]
);
const experimentalObjects = objectMetadata.filter(
({ name }) => !!experimentalObjectsInformation[name]
);
return (
<Dialog
title={<Trans>Add a new object</Trans>}
secondaryActions={<HelpButton helpPagePath="/objects" />}
actions={
<FlatButton
label={<Trans>Close</Trans>}
primary={false}
onClick={onClose}
/>
}
onRequestClose={onClose}
cannotBeDismissed={false}
open={open}
noMargin
>
<List>
{objects.map(objectMetadata => (
<ObjectListItem
key={objectMetadata.name}
objectMetadata={objectMetadata}
onClick={() => onChoose(objectMetadata.name)}
/>
))}
{showExperimental && (
<Subheader>
Experimental (make sure to read the documentation page)
</Subheader>
)}
{showExperimental &&
experimentalObjects.map(objectMetadata => (
<ObjectListItem
key={objectMetadata.name}
objectMetadata={objectMetadata}
onClick={() => onChoose(objectMetadata.name)}
/>
))}
</List>
<Line justifyContent="center" alignItems="center">
{!showExperimental ? (
<FlatButton
key="toggle-experimental"
icon={<Visibility />}
primary={false}
onClick={() => this._showExperimental(true)}
label={<Trans>Show experimental objects</Trans>}
/>
) : (
<FlatButton
key="toggle-experimental"
icon={<VisibilityOff />}
primary={false}
onClick={() => this._showExperimental(false)}
label={<Trans>Hide experimental objects</Trans>}
/>
)}
</Line>
</Dialog>
);
}
}

View File

@@ -9,7 +9,7 @@ import { AutoSizer } from 'react-virtualized';
import SortableVirtualizedItemList from '../UI/SortableVirtualizedItemList';
import Background from '../UI/Background';
import SearchBar from '../UI/SearchBar';
import NewObjectDialog from './NewObjectDialog';
import NewObjectDialog from '../AssetStore/NewObjectDialog';
import VariablesEditorDialog from '../VariablesList/VariablesEditorDialog';
import newNameGenerator from '../Utils/NewNameGenerator';
import Clipboard from '../Utils/Clipboard';
@@ -41,6 +41,11 @@ import {
import { type UnsavedChanges } from '../MainFrame/UnsavedChangesContext';
import { type HotReloadPreviewButtonProps } from '../HotReload/HotReloadPreviewButton';
import { useScreenType } from '../UI/Reponsive/ScreenTypeMeasurer';
import {
type ResourceSource,
type ChooseResourceFunction,
} from '../ResourcesList/ResourceSource.flow';
import { type ResourceExternalEditor } from '../ResourcesList/ResourceExternalEditor.flow';
const styles = {
listContainer: {
@@ -80,7 +85,12 @@ type State = {|
type Props = {|
project: gdProject,
layout: ?gdLayout,
objectsContainer: gdObjectsContainer,
resourceSources: Array<ResourceSource>,
onChooseResource: ChooseResourceFunction,
resourceExternalEditors: Array<ResourceExternalEditor>,
events: gdEventsList,
onDeleteObject: (
objectWithContext: ObjectWithContext,
cb: (boolean) => void
@@ -188,6 +198,13 @@ export default class ObjectsList extends React.Component<Props, State> {
);
};
_onObjectAddedFromAsset = (object: gdObject) => {
const { onObjectCreated } = this.props;
object.setTags(getStringFromTags(this.props.selectedObjectTags));
onObjectCreated(object);
};
onAddNewObject = () => {
this.setState({ newObjectDialogOpen: true });
};
@@ -501,7 +518,16 @@ export default class ObjectsList extends React.Component<Props, State> {
};
render() {
const { project, objectsContainer, selectedObjectTags } = this.props;
const {
project,
layout,
objectsContainer,
resourceSources,
onChooseResource,
resourceExternalEditors,
selectedObjectTags,
events,
} = this.props;
const { searchText, tagEditedObject } = this.state;
const lists = enumerateObjects(project, objectsContainer);
@@ -578,14 +604,20 @@ export default class ObjectsList extends React.Component<Props, State> {
/>
{this.state.newObjectDialogOpen && (
<NewObjectDialog
open={this.state.newObjectDialogOpen}
onClose={() =>
this.setState({
newObjectDialogOpen: false,
})
}
onChoose={this.addObject}
onCreateNewObject={this.addObject}
onObjectAddedFromAsset={this._onObjectAddedFromAsset}
project={project}
layout={layout}
objectsContainer={objectsContainer}
events={events}
resourceSources={resourceSources}
onChooseResource={onChooseResource}
resourceExternalEditors={resourceExternalEditors}
/>
)}
{this.state.variablesEditedObject && (

View File

@@ -1062,6 +1062,11 @@ export default class SceneEditor extends React.Component<Props, State> {
)}
project={project}
objectsContainer={layout}
layout={layout}
events={layout.getEvents()}
resourceSources={resourceSources}
resourceExternalEditors={resourceExternalEditors}
onChooseResource={onChooseResource}
selectedObjectNames={this.state.selectedObjectNames}
onEditObject={this.props.onEditObject || this.editObject}
onDeleteObject={this._onDeleteObject}

View File

@@ -16,6 +16,7 @@ type Props = {|
children: ?React.Node,
maxWidth?: boolean,
width?: number | string,
/** Sometimes required on Safari */
noFullHeight?: boolean,
noExpand?: boolean,
|};

View File

@@ -15,6 +15,7 @@ export const Line = props => (
alignItems: props.alignItems,
justifyContent: props.justifyContent,
flex: props.expand ? 1 : undefined,
overflow: props.overflow,
}}
>
{props.children}
@@ -36,7 +37,10 @@ export const Column = props => (
alignItems: props.alignItems || 'stretch',
justifyContent: props.justifyContent,
flex: props.expand ? 1 : undefined,
maxHeight: props.useMaxHeight ? '100%' : undefined,
// Setting the min-height to 0 forces the flex to use
// all the height (if set to flex: 1) and to *not* grow
// larger than the parent.
minHeight: props.useFullHeight ? '0' : undefined,
}}
>
{props.children}

View File

@@ -18,6 +18,8 @@ type Props = {|
// Allow to specify that the text should break words
overflow?: 'hidden',
overflowWrap?: 'break-word',
whiteSpace?: 'nowrap',
textOverflow?: 'ellipsis',
// Allow to expand the text
flex?: 1,

View File

@@ -214,6 +214,30 @@ export const sendSubscriptionDialogShown = () => {
client.recordEvent('subscription-dialog-shown', {});
};
export const sendAssetOpened = ({
id,
name,
}: {|
id: string,
name: string,
|}) => {
if (isDev || !client) return;
client.recordEvent('asset-opened', { id, name });
};
export const sendAssetAddedToProject = ({
id,
name,
}: {|
id: string,
name: string,
|}) => {
if (isDev || !client) return;
client.recordEvent('asset-added-to-project', { id, name });
};
export const sendChoosePlanClicked = (planId: string | null) => {
if (isDev || !client) return;

View File

@@ -37,3 +37,9 @@ export const GDevelopExtensionApi = {
? 'https://raw.githubusercontent.com/4ian/GDevelop-extensions/master'
: 'https://raw.githubusercontent.com/4ian/GDevelop-extensions/master',
};
export const GDevelopAssetApi = {
baseUrl: isDev
? 'https://57l4cj31aj.execute-api.us-east-1.amazonaws.com/dev'
: 'TODO',
};

View File

@@ -0,0 +1,142 @@
// @flow
import axios from 'axios';
import { GDevelopAssetApi } from './ApiConfigs';
import semverSatisfies from 'semver/functions/satisfies';
export type SerializedParameterMetadata = {|
codeOnly: boolean,
defaultValue: string,
description: string,
longDescription: string,
name: string,
optional: boolean,
supplementaryInformation: string,
type: string, // See ParameterRenderingService for valid types.
|};
export type AssetCustomization =
| {|
required: boolean,
extensionName: string,
extensionVersion: string,
behaviorName: string,
behaviorType: string,
properties: Array<SerializedParameterMetadata>,
|}
| {|
required: boolean,
events: any /*(serialized gdEventsList)*/,
parameters: Array<SerializedParameterMetadata>,
extensions: Array<{|
extensionName: string,
extensionVersion: string,
|}>,
|};
export type ObjectAsset = {|
object: any /*(serialized gdObject)*/,
resources: Array<any /*(serialized gdResource)*/>,
customization: Array<AssetCustomization>,
|};
export type AssetShortHeader = {|
id: string,
name: string,
shortDescription: string,
previewImageUrls: Array<string>,
tags: Array<string>,
|};
export type AssetHeader = {|
...AssetShortHeader,
gdevelopVersion: string,
version: string,
description: string,
|};
export type Asset = {|
...AssetHeader,
authors: Array<string>,
license: string,
objectAssets: Array<ObjectAsset>,
|};
export type TagsTreeNode = {|
name: string,
children: Array<TagsTreeNode>,
allChildrenTags: Array<string>,
|};
export type Filters = {|
allTags: Array<string>,
defaultTags: Array<string>,
tagsTree: Array<TagsTreeNode>,
|};
export type AllAssets = {|
assetShortHeaders: Array<AssetShortHeader>,
filters: Filters,
|};
export type Resource = {|
url: string,
type: string,
tags: Array<string>,
|};
export type AllResources = {|
resources: Array<Resource>,
filters: Filters,
|};
/** Check if the IDE version, passed as argument, satisfy the version required by the asset. */
export const isCompatibleWithAsset = (
ideVersion: string,
assetHeader: AssetHeader
) =>
assetHeader.gdevelopVersion
? semverSatisfies(ideVersion, assetHeader.gdevelopVersion, {
includePrerelease: true,
})
: true;
export const listAllAssets = (): Promise<AllAssets> => {
return axios
.get(`${GDevelopAssetApi.baseUrl}/asset`)
.then(response => response.data)
.then(({ assetShortHeadersUrl, filtersUrl }) => {
return Promise.all([
axios.get(assetShortHeadersUrl).then(response => response.data),
axios.get(filtersUrl).then(response => response.data),
]).then(([assetShortHeaders, filters]) => ({
assetShortHeaders,
filters,
}));
});
};
export const getAsset = (
assetShortHeader: AssetShortHeader
): Promise<Asset> => {
return axios
.get(`${GDevelopAssetApi.baseUrl}/asset/${assetShortHeader.id}`)
.then(response => response.data)
.then(({ assetUrl }) => axios.get(assetUrl))
.then(response => response.data);
};
export const listAllResources = (): Promise<AllResources> => {
return axios
.get(`${GDevelopAssetApi.baseUrl}/resource`)
.then(response => response.data)
.then(({ resourcesUrl, filtersUrl }) => {
return Promise.all([
axios.get(resourcesUrl).then(response => response.data),
axios.get(filtersUrl).then(response => response.data),
]).then(([resources, filters]) => ({
resources,
filters,
}));
});
};

View File

@@ -7,7 +7,12 @@ import {
import { type Profile } from '../Utils/GDevelopServices/Authentification';
import { type Release } from '../Utils/GDevelopServices/Release';
import { type Build } from '../Utils/GDevelopServices/Build';
import { type ExtensionShortHeader } from '../Utils/GDevelopServices/Extension';
import { type UserProfile } from '../Profile/UserProfileContext';
import {
type AssetShortHeader,
type Asset,
} from '../Utils/GDevelopServices/Asset';
export const profileForIndieUser: Profile = {
uid: 'indie-user',
@@ -217,3 +222,387 @@ export const completeWebBuild: Build = {
s3Key: 'game-12345',
updatedAt: Date.now(),
};
const spaceshipSerializedObject = {
name: 'PlayerSpaceship',
tags: '',
type: 'Sprite',
updateIfNotVisible: false,
variables: [
{
name: 'State',
value: 'Spaceship',
},
],
behaviors: [],
animations: [
{
name: 'SpaceshipIdle',
useMultipleDirections: false,
directions: [
{
looping: true,
timeBetweenFrames: 0.08,
sprites: [
{
hasCustomCollisionMask: false,
image: 'player-ship1.png',
points: [],
originPoint: {
name: 'origine',
x: 0,
y: 0,
},
centerPoint: {
automatic: true,
name: 'centre',
x: 0,
y: 0,
},
customCollisionMask: [],
},
{
hasCustomCollisionMask: false,
image: 'player-ship2.png',
points: [],
originPoint: {
name: 'origine',
x: 0,
y: 0,
},
centerPoint: {
automatic: true,
name: 'centre',
x: 0,
y: 0,
},
customCollisionMask: [],
},
],
},
],
},
],
};
const spaceshipSerializedResources = [
{
alwaysLoaded: false,
file: 'https://example.com/player-ship1.png',
kind: 'image',
metadata: '',
name: 'player-ship1.png',
smoothed: false,
userAdded: false,
},
{
alwaysLoaded: false,
file: 'https://example.com/player-ship2.png',
kind: 'image',
metadata: '',
name: 'player-ship2.png',
smoothed: false,
userAdded: false,
},
];
export const fakeAsset1: Asset = {
id: '123',
name: 'My spaceship',
shortDescription:
'A spaceship that can be moved with the keyboard or by touching the screen',
description: "A very nice way to start a shoot'em up.",
previewImageUrls: ['res/GD-logo.png'],
gdevelopVersion: '5.0.0-beta100',
version: '1.0.0',
authors: ['test author'],
license: 'MIT',
tags: ['space shooter', 'tag2'],
objectAssets: [
{
object: spaceshipSerializedObject,
resources: spaceshipSerializedResources,
customization: [],
},
],
};
export const fakeAssetWithBehaviorCustomizations1: Asset = {
id: '123',
name: 'My spaceship',
shortDescription:
'A spaceship that can be moved with the keyboard or by touching the screen',
description: "A very nice way to start a shoot'em up.",
previewImageUrls: ['res/GD-logo.png'],
gdevelopVersion: '5.0.0-beta100',
version: '1.0.0',
authors: ['test author'],
license: 'MIT',
tags: ['space shooter', 'tag2'],
objectAssets: [
{
object: spaceshipSerializedObject,
resources: spaceshipSerializedResources,
customization: [
{
behaviorName: 'MyBehavior',
behaviorType: 'FakeBehavior::FakeBehavior',
required: true,
extensionName: 'FakeBehavior',
extensionVersion: '1.0.0',
properties: [
{
codeOnly: false,
description: 'Example of a parameter',
longDescription: '',
supplementaryInformation: '',
optional: false,
type: 'string',
name: 'property1',
defaultValue: 'Overriden value',
},
],
},
],
},
],
};
export const fakeAssetWithUnknownBehaviorCustomizations1: Asset = {
id: '123',
name: 'My spaceship',
shortDescription:
'A spaceship that can be moved with the keyboard or by touching the screen',
description: "A very nice way to start a shoot'em up.",
previewImageUrls: ['res/GD-logo.png'],
gdevelopVersion: '5.0.0-beta100',
version: '1.0.0',
authors: ['test author'],
license: 'MIT',
tags: ['space shooter', 'tag2'],
objectAssets: [
{
object: spaceshipSerializedObject,
resources: spaceshipSerializedResources,
customization: [
{
behaviorName: 'MyUnknownBehavior',
behaviorType: 'UnknownBehavior::UnknownBehavior',
required: true,
extensionName: 'UnknownBehavior',
extensionVersion: '1.0.0',
properties: [],
},
],
},
],
};
export const fakeAssetWithFlashBehaviorCustomizations1: Asset = {
id: '123',
name: 'My spaceship',
shortDescription:
'A spaceship that can be moved with the keyboard or by touching the screen',
description: "A very nice way to start a shoot'em up.",
previewImageUrls: ['res/GD-logo.png'],
gdevelopVersion: '5.0.0-beta100',
version: '1.0.0',
authors: ['test author'],
license: 'MIT',
tags: ['space shooter', 'tag2'],
objectAssets: [
{
object: spaceshipSerializedObject,
resources: spaceshipSerializedResources,
customization: [
{
behaviorName: 'MyFlashBehavior',
behaviorType: 'Flash::Flash',
required: true,
extensionName: 'Flash',
extensionVersion: '1.0.0',
properties: [],
},
],
},
],
};
export const fakeAssetWithEventCustomizationsAndFlashExtension1: Asset = {
id: '123',
name: 'My spaceship',
shortDescription:
'A spaceship that can be moved with the keyboard or by touching the screen',
description: "A very nice way to start a shoot'em up.",
previewImageUrls: ['res/GD-logo.png'],
gdevelopVersion: '5.0.0-beta100',
authors: ['test author'],
license: 'MIT',
version: '1.0.0',
tags: ['space shooter', 'tag2'],
objectAssets: [
{
object: spaceshipSerializedObject,
resources: spaceshipSerializedResources,
customization: [
{
required: true,
extensions: [
{
extensionName: 'Flash', // Not really used in events, just for tests.
extensionVersion: '1.0.0',
},
],
events: [
{
disabled: false,
folded: false,
type: 'BuiltinCommonInstructions::Standard',
conditions: [
{
type: { inverted: false, value: 'VarScene' },
parameters: [
'Counter',
'<',
'TEXT_TO_REPLACE + PlayerSpaceship.Variable(test)',
],
subInstructions: [],
},
],
actions: [
{
type: { inverted: false, value: 'ModVarScene' },
parameters: [
'Counter',
'=',
'TEXT_TO_REPLACE + PlayerSpaceship.Variable(test2)',
],
subInstructions: [],
},
],
events: [],
},
],
parameters: [
{
codeOnly: false,
description: 'Example of a parameter',
longDescription: '',
supplementaryInformation: '',
optional: false,
type: 'string',
name: 'TEXT_TO_REPLACE',
defaultValue: '3',
},
],
},
],
},
],
};
export const fakeAssetWithEventCustomizationsAndUnknownExtension1: Asset = {
id: '123',
name: 'My spaceship',
shortDescription:
'A spaceship that can be moved with the keyboard or by touching the screen',
description: "A very nice way to start a shoot'em up.",
previewImageUrls: ['res/GD-logo.png'],
gdevelopVersion: '5.0.0-beta100',
authors: ['test author'],
license: 'MIT',
version: '1.0.0',
tags: ['space shooter', 'tag2'],
objectAssets: [
{
object: spaceshipSerializedObject,
resources: spaceshipSerializedResources,
customization: [
{
required: true,
extensions: [
{
extensionName: 'UnknownExtension', // Not really used in events, just for tests.
extensionVersion: '1.0.0',
},
],
events: [
{
disabled: false,
folded: false,
type: 'BuiltinCommonInstructions::Standard',
conditions: [],
actions: [],
events: [],
},
],
parameters: [
{
codeOnly: false,
description: 'Example of a parameter',
longDescription: '',
supplementaryInformation: '',
optional: false,
type: 'string',
name: 'EXAMPLE_PARAMETER',
defaultValue: 'Hello World',
},
],
},
],
},
],
};
export const fakeAssetShortHeader1: AssetShortHeader = {
id: '123',
name: 'My spaceship',
shortDescription:
'A spaceship that can be moved with the keyboard or by touching the screen',
previewImageUrls: ['res/GD-logo.png'],
tags: ['space shooter', 'tag2'],
};
export const fakeAssetShortHeader2: AssetShortHeader = {
id: '456',
name: 'Zombie',
shortDescription: 'A zombie attacking the player and wandering around.',
previewImageUrls: ['res/GD-logo.png'],
tags: ['survival', 'tag2'],
};
export const fakeAssetShortHeader3: AssetShortHeader = {
id: '789',
name: 'Sword',
shortDescription: 'A small sword.',
previewImageUrls: ['res/GD-logo.png'],
tags: ['medieval', 'tag2'],
};
export const fireBulletExtensionShortHeader: ExtensionShortHeader = {
shortDescription:
'Allow the object to fire bullets, with customizable speed, angle and fire rate.',
extensionNamespace: '',
fullName: 'Fire bullets',
name: 'FireBullet',
version: '0.0.2',
url: 'Extensions/FireBullet.json',
headerUrl: 'Extensions/FireBullet-header.json',
tags: 'fire, bullets, spawn, firerate',
eventsBasedBehaviorsCount: 1,
eventsFunctionsCount: 0,
};
export const flashExtensionShortHeader: ExtensionShortHeader = {
shortDescription:
'Make the object flash (blink) for a period of time, so that it is alternately visible and invisible.\nTrigger the effect by using the Flash action.',
extensionNamespace: '',
fullName: 'Flash (blink)',
name: 'Flash',
version: '1.0.0',
url: 'Extensions/Flash.json',
headerUrl: 'Extensions/Flash-header.json',
tags: 'flash, blink, visible, invisible, hit, damage',
eventsBasedBehaviorsCount: 1,
eventsFunctionsCount: 0,
};

View File

@@ -95,6 +95,70 @@ export const makeTestExtensions = (gd: libGDevelop) => {
platform.addNewExtension(extension);
extension.delete(); // Release the extension as it was copied inside gd.JsPlatform
}
{
const extension = new gd.PlatformExtension();
extension.setExtensionInformation(
'FakeBehavior',
'Fake extension with a fake behavior',
'A fake extension with a fake behavior containing 2 properties.',
'',
'MIT'
);
const fakeBehavior = new gd.BehaviorJsImplementation();
// $FlowExpectedError - ignore Flow warning as we're creating a behavior
fakeBehavior.updateProperty = function(
behaviorContent,
propertyName,
newValue
) {
if (propertyName === 'property1') {
behaviorContent.setStringAttribute('property1', newValue);
return true;
}
if (propertyName === 'property2') {
behaviorContent.setBoolAttribute('property2', newValue === '1');
return true;
}
return false;
};
// $FlowExpectedError - ignore Flow warning as we're creating a behavior
fakeBehavior.getProperties = function(behaviorContent) {
var behaviorProperties = new gd.MapStringPropertyDescriptor();
behaviorProperties
.getOrCreate('property1')
.setValue(behaviorContent.getStringAttribute('property1'));
behaviorProperties
.getOrCreate('property2')
.setValue(
behaviorContent.getBoolAttribute('property2') ? 'true' : 'false'
)
.setType('Boolean');
return behaviorProperties;
};
// $FlowExpectedError - ignore Flow warning as we're creating a behavior
fakeBehavior.initializeContent = function(behaviorContent) {
behaviorContent.setStringAttribute('property1', 'Initial value 1');
behaviorContent.setBoolAttribute('property2', true);
};
extension.addBehavior(
'FakeBehavior',
'Fake behavior with two properties',
'FakeBehavior', // Default name is the name
'A fake behavior with two properties.',
'',
'res/function24.png',
'FakeBehavior', // Class name is the name, actually unused
fakeBehavior,
new gd.BehaviorsSharedData()
);
platform.addNewExtension(extension);
extension.delete(); // Release the extension as it was copied inside gd.JsPlatform
}
{
const extension = new gd.PlatformExtension();
extension.setExtensionInformation(
@@ -105,6 +169,21 @@ export const makeTestExtensions = (gd: libGDevelop) => {
'MIT'
);
const fakeBehavior = new gd.BehaviorJsImplementation();
// $FlowExpectedError - ignore Flow warning as we're creating a behavior
fakeBehavior.updateProperty = function(
behaviorContent,
propertyName,
newValue
) {
return false;
};
// $FlowExpectedError - ignore Flow warning as we're creating a behavior
fakeBehavior.getProperties = function(behaviorContent) {
var behaviorProperties = new gd.MapStringPropertyDescriptor();
return behaviorProperties;
};
// $FlowExpectedError - ignore Flow warning as we're creating a behavior
fakeBehavior.initializeContent = function(behaviorContent) {};
extension
.addBehavior(

View File

@@ -99,6 +99,11 @@ import {
completeCordovaBuild,
completeElectronBuild,
completeWebBuild,
fakeAssetShortHeader1,
fakeAssetShortHeader2,
fakeAssetShortHeader3,
fakeAssetWithBehaviorCustomizations1,
fakeAssetWithEventCustomizationsAndFlashExtension1,
} from '../fixtures/GDevelopServicesTestData';
import debuggerGameDataDump from '../fixtures/DebuggerGameDataDump.json';
import profilerOutput from '../fixtures/ProfilerOutputsTestData.json';
@@ -152,7 +157,7 @@ import EffectsList from '../EffectsList';
import SubscriptionPendingDialog from '../Profile/SubscriptionPendingDialog';
import Dialog from '../UI/Dialog';
import MiniToolbar, { MiniToolbarText } from '../UI/MiniToolbar';
import NewObjectDialog from '../ObjectsList/NewObjectDialog';
import NewObjectDialog from '../AssetStore/NewObjectDialog';
import { Column, Line } from '../UI/Grid';
import DragAndDropTestBed from './DragAndDropTestBed';
import EditorMosaic from '../UI/EditorMosaic';
@@ -200,6 +205,16 @@ import HotReloadPreviewButton, {
type HotReloadPreviewButtonProps,
} from '../HotReload/HotReloadPreviewButton';
import HotReloadLogsDialog from '../HotReload/HotReloadLogsDialog';
import { AssetStore } from '../AssetStore';
import { AssetStoreStateProvider } from '../AssetStore/AssetStoreContext';
import ScrollView from '../UI/ScrollView';
import '../UI/Theme/Global.css';
import { AssetCard } from '../AssetStore/AssetCard';
import { useFilters } from '../AssetStore/FiltersChooser';
import { SearchResults } from '../AssetStore/SearchResults';
import { AssetDetails } from '../AssetStore/AssetDetails';
import CustomizationFields from '../AssetStore/CustomizationFields';
configureActions({
depth: 2,
@@ -735,6 +750,33 @@ storiesOf('UI Building Blocks/SearchBar', module)
/>
));
storiesOf('UI Building Blocks/Layout/Grid', module)
.addDecorator(muiDecorator)
.add('Line and ScrollView in a fixed height container', () => (
<FixedHeightFlexContainer height={100}>
<Column expand>
<Line overflow="hidden">
<ScrollView>
<Text>123</Text>
<Text>456</Text>
<Text>789</Text>
<Text>123</Text>
<Text>456</Text>
<Text>789</Text>
</ScrollView>
<ScrollView>
<Text>123</Text>
<Text>456</Text>
<Text>789</Text>
<Text>123</Text>
<Text>456</Text>
<Text>789</Text>
</ScrollView>
</Line>
</Column>
</FixedHeightFlexContainer>
));
storiesOf('UI Building Blocks/Layout/ResponsiveLineStackLayout', module)
.addDecorator(muiDecorator)
.add('Default', () => (
@@ -1585,6 +1627,11 @@ storiesOf('UI Building Blocks/ClosableTabs', module)
getThumbnail={() => 'res/unknown32.png'}
project={testProject.project}
objectsContainer={testProject.testLayout}
layout={testProject.testLayout}
events={testProject.testLayout.getEvents()}
resourceSources={[]}
onChooseResource={() => Promise.reject('unimplemented')}
resourceExternalEditors={fakeResourceExternalEditors}
onEditObject={action('On edit object')}
selectedObjectNames={[]}
selectedObjectTags={[]}
@@ -3238,6 +3285,11 @@ storiesOf('ObjectsList', module)
getThumbnail={() => 'res/unknown32.png'}
project={testProject.project}
objectsContainer={testProject.testLayout}
layout={testProject.testLayout}
events={testProject.testLayout.getEvents()}
resourceSources={[]}
onChooseResource={() => Promise.reject('unimplemented')}
resourceExternalEditors={fakeResourceExternalEditors}
onEditObject={action('On edit object')}
onObjectCreated={action('On object created')}
selectedObjectNames={[]}
@@ -3262,6 +3314,11 @@ storiesOf('ObjectsList', module)
getThumbnail={() => 'res/unknown32.png'}
project={testProject.project}
objectsContainer={testProject.testLayout}
layout={testProject.testLayout}
events={testProject.testLayout.getEvents()}
resourceSources={[]}
onChooseResource={() => Promise.reject('unimplemented')}
resourceExternalEditors={fakeResourceExternalEditors}
onEditObject={action('On edit object')}
onObjectCreated={action('On object created')}
selectedObjectNames={[]}
@@ -4322,10 +4379,18 @@ storiesOf('NewObjectDialog', module)
.addDecorator(muiDecorator)
.add('default', () => (
<NewObjectDialog
open
project={testProject.project}
onClose={action('close')}
onChoose={action('choose')}
layout={testProject.testLayout}
onClose={action('onClose')}
onCreateNewObject={action('onCreateNewObject')}
events={testProject.testLayout.getEvents()}
objectsContainer={testProject.testLayout}
resourceExternalEditors={fakeResourceExternalEditors}
onChooseResource={() => {
action('onChooseResource');
return Promise.reject();
}}
resourceSources={[]}
/>
));
@@ -4432,3 +4497,139 @@ storiesOf('HotReloadLogsDialog', module)
onLaunchNewPreview={() => {}}
/>
));
storiesOf('AssetStore', module)
.addDecorator(muiDecorator)
.add('default', () => (
<FixedHeightFlexContainer height={400}>
<AssetStoreStateProvider>
<AssetStore
onOpenDetails={action('onOpenDetails')}
events={testProject.testLayout.getEvents()}
project={testProject.project}
objectsContainer={testProject.testLayout}
/>
</AssetStoreStateProvider>
</FixedHeightFlexContainer>
));
storiesOf('AssetStore/SearchResults', module)
.addDecorator(muiDecorator)
.add('loading', () => (
<SearchResults
error={null}
assetShortHeaders={null}
onRetry={action('onRetry')}
onOpenDetails={action('onOpenDetails')}
/>
))
.add('errored', () => (
<SearchResults
error={new Error('Fake error')}
assetShortHeaders={null}
onRetry={action('onRetry')}
onOpenDetails={action('onOpenDetails')}
/>
))
.add('no results', () => (
<SearchResults
error={null}
assetShortHeaders={[]}
onRetry={action('onRetry')}
onOpenDetails={action('onOpenDetails')}
/>
))
.add('some results', () => (
<SearchResults
error={null}
assetShortHeaders={[
fakeAssetShortHeader1,
fakeAssetShortHeader2,
fakeAssetShortHeader3,
]}
onRetry={action('onRetry')}
onOpenDetails={action('onOpenDetails')}
/>
));
storiesOf('AssetStore/AssetCard', module)
.addDecorator(muiDecorator)
.add('default', () => (
<AssetCard
size={128}
onOpenDetails={action('onOpenDetails')}
assetShortHeader={fakeAssetShortHeader1}
/>
));
storiesOf('AssetStore/AssetDetails', module)
.addDecorator(paperDecorator)
.addDecorator(muiDecorator)
.add('default', () => (
<AssetDetails
canInstall={true}
isBeingInstalled={false}
onAdd={action('onAdd')}
onClose={action('onClose')}
assetShortHeader={fakeAssetShortHeader1}
project={testProject.project}
objectsContainer={testProject.testLayout}
layout={testProject.testLayout}
resourceExternalEditors={fakeResourceExternalEditors}
onChooseResource={() => {
action('onChooseResource');
return Promise.reject();
}}
resourceSources={[]}
/>
))
.add('being installed', () => (
<AssetDetails
canInstall={false}
isBeingInstalled={true}
onAdd={action('onAdd')}
onClose={action('onClose')}
assetShortHeader={fakeAssetShortHeader1}
project={testProject.project}
objectsContainer={testProject.testLayout}
layout={testProject.testLayout}
resourceExternalEditors={fakeResourceExternalEditors}
onChooseResource={() => {
action('onChooseResource');
return Promise.reject();
}}
resourceSources={[]}
/>
));
storiesOf('AssetStore/CustomizationFields', module)
.addDecorator(paperDecorator)
.addDecorator(muiDecorator)
.add('with behavior customizations', () => (
<CustomizationFields
asset={fakeAssetWithBehaviorCustomizations1}
project={testProject.project}
objectsContainer={testProject.testLayout}
layout={testProject.testLayout}
resourceExternalEditors={fakeResourceExternalEditors}
onChooseResource={() => {
action('onChooseResource');
return Promise.reject();
}}
resourceSources={[]}
/>
))
.add('with event customizations', () => (
<CustomizationFields
asset={fakeAssetWithEventCustomizationsAndFlashExtension1}
project={testProject.project}
objectsContainer={testProject.testLayout}
layout={testProject.testLayout}
resourceExternalEditors={fakeResourceExternalEditors}
onChooseResource={() => {
action('onChooseResource');
return Promise.reject();
}}
resourceSources={[]}
/>
));

View File

@@ -7287,6 +7287,11 @@ flow-bin@0.120.1:
resolved "https://registry.yarnpkg.com/flow-bin/-/flow-bin-0.120.1.tgz#ab051d6df71829b70a26a2c90bb81f9d43797cae"
integrity sha512-KgE+d+rKzdXzhweYVJty1QIOOZTTbtnXZf+4SLnmArLvmdfeLreQOZpeLbtq5h79m7HhDzX/HkUkoyu/fmSC2A==
flow-bin@^0.50.0:
version "0.50.0"
resolved "https://registry.yarnpkg.com/flow-bin/-/flow-bin-0.50.0.tgz#d4cdb2430dee1a3599f0eb6fe551146e3027256a"
integrity sha1-1M2yQw3uGjWZ8Otv5VEUbjAnJWo=
flow-coverage-report@^0.4.0:
version "0.4.1"
resolved "https://registry.yarnpkg.com/flow-coverage-report/-/flow-coverage-report-0.4.1.tgz#f757954024a4d11de86d8a05040042f7b78e41ab"
@@ -9457,6 +9462,14 @@ js-tokens@^3.0.2:
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-3.0.2.tgz#9866df395102130e38f7f996bceb65443209c25b"
integrity sha1-mGbfOVECEw449/mWvOtlRDIJwls=
js-worker-search@^1.4.1:
version "1.4.1"
resolved "https://registry.yarnpkg.com/js-worker-search/-/js-worker-search-1.4.1.tgz#beb5ac0df3f37900ff2ad676951833d3913b3192"
integrity sha512-5KCio6angmP+AXX8X2pPtjw1h3BCrmEmwGv2MdkXhl3+7g/2NTHOMhUh8bZqacp8R9LjhTT2bGr+f7RdB8KFZw==
dependencies:
flow-bin "^0.50.0"
uuid "^2.0.1"
js-yaml@^3.13.1:
version "3.14.0"
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.0.tgz#a7a34170f26a21bb162424d8adacb4113a69e482"
@@ -15452,6 +15465,11 @@ utils-merge@1.0.1:
resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=
uuid@^2.0.1:
version "2.0.3"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-2.0.3.tgz#67e2e863797215530dff318e5bf9dcebfd47b21a"
integrity sha1-Z+LoY3lyFVMN/zGOW/nc6/1Hsho=
uuid@^3.0.1, uuid@^3.3.2:
version "3.4.0"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee"