Compare commits

...

136 Commits

Author SHA1 Message Date
Fannie Yan
75a9a66f2e Merge game management button into one 2022-04-14 09:58:53 +02:00
Fannie Yan
d94b2524ff Fix overflowing game tabs in Games Dashboard 2022-04-11 17:28:03 +02:00
AlexandreSi
6c57f24691 Add Leaderboards extension to browser app extensions 2022-04-06 14:39:55 +02:00
AlexandreSi
26c5aff685 Use ternary to improve readability 2022-04-06 14:39:55 +02:00
AlexandreSi
68662b845b Return promise that reads response text from savePlayerScore function 2022-04-06 14:39:55 +02:00
AlexandreSi
5111baff54 Remove type unsafe field accesses 2022-04-06 14:39:55 +02:00
AlexandreSi
78bded02b4 Improve naming 2022-04-06 14:39:55 +02:00
AlexandreSi
f6f9d8ca78 Add details on leaderboard visibility parameter 2022-04-06 14:39:55 +02:00
AlexandreSi
7caa7ea767 Fix flow 2022-04-06 14:39:55 +02:00
AlexandreSi
f809595ec9 Prettier 2022-04-06 14:39:55 +02:00
AlexandreSi
f6367897ff Remove quite useless logs 2022-04-06 14:39:55 +02:00
AlexandreSi
3550b91ff7 Improve leaderboard id field rendering with long description instead of extra info and new rendering when optional 2022-04-06 14:39:55 +02:00
AlexandreSi
c277968d5f Differentiate save actions that successfully ended and the others in ambiguous conditions 2022-04-06 14:39:55 +02:00
AlexandreSi
06cc69ea57 Store score saving data per leaderboard to allow parallel actions to be run 2022-04-06 14:39:55 +02:00
AlexandreSi
852223f2eb Add eslint ignore on effect that fetches entries on admin opening 2022-04-06 14:39:55 +02:00
Florian Rival
13ec8378e7 Refresh leaderboard entries when the leaderboard admin is loaded 2022-04-06 14:39:55 +02:00
Florian Rival
8440835266 Ignore requests to display the same leaderboard if already loading or loaded
* Also ensure the leaderboard "loaded" state is reset back to false when closed.
2022-04-06 14:39:55 +02:00
AlexandreSi
df2bbd224d Prettier 2022-04-06 14:39:55 +02:00
AlexandreSi
935f9026ce Fix flow 2022-04-06 14:39:55 +02:00
AlexandreSi
eb0194ab58 Add possibility to change leaderboard visibility in admin 2022-04-06 14:39:55 +02:00
AlexandreSi
6d4e1d2d30 Adapt Play api to new endpoints 2022-04-06 14:39:55 +02:00
AlexandreSi
9de099d582 Add logic to prevent sending 1 score per frame if trigger once condition not set 2022-04-06 14:39:55 +02:00
AlexandreSi
9d3d78627e Make saving score action and conditions uniform with displaying leaderboard action and conditions 2022-04-06 14:39:55 +02:00
AlexandreSi
a5a6f9845c Replace all white spaces with underscore in player name 2022-04-06 14:39:55 +02:00
AlexandreSi
6652fb6156 Normalize player name in order to remove diacritics where they can be removed 2022-04-06 14:39:55 +02:00
AlexandreSi
d19d4b314d Prettier 2022-04-06 14:39:55 +02:00
AlexandreSi
1da192072b Refocus canvas after trying to close leaderboard view, whatever happens 2022-04-06 14:39:55 +02:00
AlexandreSi
1c5e825275 Fix loader display timing 2022-04-06 14:39:55 +02:00
AlexandreSi
a25a76e0cc Hide leaderboard before it's loaded regardless of loader display 2022-04-06 14:39:55 +02:00
AlexandreSi
fe50095403 Prettier 2022-04-06 14:39:55 +02:00
AlexandreSi
c60d23c2c8 Change method names to clearly separate score submission from leaderboard display 2022-04-06 14:39:55 +02:00
AlexandreSi
7a2bdb36c3 Change a few error messages 2022-04-06 14:39:55 +02:00
AlexandreSi
6aedecd520 Refactor a few functions to improve readability and display loader when just changing leaderboard to be displayed 2022-04-06 14:39:55 +02:00
AlexandreSi
52c68dd819 Add background color to iframe 2022-04-06 14:39:55 +02:00
AlexandreSi
c5b5c26949 Discard leaderboard availability check if outdated 2022-04-06 14:39:55 +02:00
AlexandreSi
61ff3bb6ee Improve leaderboard extension wording 2022-04-06 14:39:55 +02:00
AlexandreSi
675630e6f0 Reduce catch scope 2022-04-06 14:39:55 +02:00
AlexandreSi
9045dfae99 Add try catch around loader animate call 2022-04-06 14:39:55 +02:00
AlexandreSi
7c294133f3 Add 3 states when displaying leaderboards and add possibility to display a loader while loading 2022-04-06 14:39:55 +02:00
AlexandreSi
d7dec04093 Add query param when requesting leaderboard page 2022-04-06 14:39:55 +02:00
AlexandreSi
05ed53b657 Improve english 2022-04-06 14:39:55 +02:00
AlexandreSi
f86d5ac8e0 Use leaderboards logger for logging 2022-04-06 14:39:55 +02:00
AlexandreSi
114a4dfb24 Add event listener in game to close leaderboard view message reception 2022-04-06 14:39:55 +02:00
AlexandreSi
cd7b1dc559 Check that leaderboard data can be fetched before displaying iframe 2022-04-06 14:39:55 +02:00
AlexandreSi
d6c8d24c5a Add actions to display and hide leaderboard view 2022-04-06 14:39:55 +02:00
AlexandreS
92ab91daf3 Improve UX with game not registered or with 0 leaderboard 2022-04-06 14:39:55 +02:00
Fannie Yan
6a56b1609e Save additional info with leaderboard entry (#3783)
Don't mention in the changelog
2022-04-06 14:39:55 +02:00
Fannie Yan
5ab291df10 Allow users to save scores in leaderboards (#3766)
* Add action to save scores in given leaderboard
* Add condition and expression for last leaderboard entry save status
* Add expression to format player name to send it to leaderboard
2022-04-06 14:39:55 +02:00
AlexandreSi
d9ca2fd364 Fix new instruction editor dialog display 2022-04-06 14:39:22 +02:00
AlexandreSi
b28f2b663a Set key in fragment to fix react error 2022-04-06 14:39:22 +02:00
AlexandreSi
dd8a24e0ff Use fullwidth props - skip tests 2022-04-06 14:39:22 +02:00
AlexandreSi
e96b7f606f Replace leaderboard autocomplete with native select 2022-04-06 14:39:22 +02:00
AlexandreSi
0e95211929 Revert leaderboard player unicity display choice select to native one 2022-04-06 14:39:22 +02:00
AlexandreSi
f7c94640be Use Text component instead of Typography component for all text display in leaderboard admin 2022-04-06 14:39:22 +02:00
AlexandreSi
4c859ce86d Make it possible to use Text component as child of tooltip 2022-04-06 14:39:22 +02:00
AlexandreSi
b3b8611729 Add props to specify Text component color 2022-04-06 14:39:22 +02:00
AlexandreSi
6bacd7ae6d Fix Select onChange function 2022-04-06 14:39:22 +02:00
AlexandreSi
9398e6b58a Use the same font size for player unicity display choice menu items as for select input 2022-04-06 14:39:22 +02:00
AlexandreSi
e7adc1205a Use select for playerUnicityDisplayChoice to match leaderboard select design 2022-04-06 14:39:22 +02:00
AlexandreSi
6a9ef654dc Display loader when creating new leaderboard 2022-04-06 14:39:22 +02:00
AlexandreSi
7c1da4ede4 Use px insteads of em to specify SelectField font size valid value 2022-04-06 14:39:22 +02:00
AlexandreSi
cfb8583e51 Remove useless flexRowBody option for dialog content 2022-04-06 14:39:22 +02:00
AlexandreSi
b2a42445ed Fix mock leaderboard uuid in story 2022-04-06 14:39:22 +02:00
AlexandreSi
13e49127cd Rename ill-named method 2022-04-06 14:39:22 +02:00
AlexandreSi
f8aafa9b5f Improve error messages 2022-04-06 14:39:22 +02:00
AlexandreSi
a1ffa3cf83 Improve props setting when function is not defined 2022-04-06 14:39:22 +02:00
AlexandreSi
16eec3dd77 Ternary > let null then if 2022-04-06 14:39:22 +02:00
AlexandreSi
651c25873c Return state instead of throwing when receiving unknown action type 2022-04-06 14:39:22 +02:00
AlexandreSi
695e39f87a Prevent defining a new function for the retry button of placeholder error component 2022-04-06 14:39:22 +02:00
AlexandreSi
8ff1882e58 Disable dialog close button when loading instead of just displaying a loader 2022-04-06 14:39:22 +02:00
AlexandreSi
282fc9e445 Use an englisher sentence 2022-04-06 14:39:22 +02:00
AlexandreSi
698942b969 Fix component methods naming 2022-04-06 14:39:22 +02:00
AlexandreSi
89eaa923b7 Use shouldValidate function to validate leaderboard name 2022-04-06 14:39:22 +02:00
AlexandreSi
7d6e648329 Fix naming for function 2022-04-06 14:39:22 +02:00
AlexandreSi
b389cc4c0c Improve error logging 2022-04-06 14:39:22 +02:00
AlexandreSi
7ceb416285 Improve flow type annotations 2022-04-06 14:39:22 +02:00
AlexandreSi
ecf786e3e9 Improve imports 2022-04-06 14:39:22 +02:00
AlexandreSi
12ab4948ee Add story to test component error display 2022-04-06 14:39:22 +02:00
AlexandreSi
bdb102d7bb Use same font size on leaderboard name text field 2022-04-06 14:39:22 +02:00
AlexandreSi
4a0fccf62c Tidy tooltips 2022-04-06 14:39:22 +02:00
AlexandreSi
4d9e815fa6 Fix leaderboard autocomplete equality test 2022-04-06 14:39:22 +02:00
AlexandreSi
64de2aaec5 Fetch same page entries when removing an entry 2022-04-06 14:39:22 +02:00
AlexandreSi
dd3b6bdd7c Add tooltips to pagination 2022-04-06 14:39:22 +02:00
AlexandreSi
3053452c59 Add FlowFixMe on Flow known error 2022-04-06 14:39:22 +02:00
AlexandreSi
bfae29ea01 Test extractNextPageUriFromLinkHeader 2022-04-06 14:39:22 +02:00
AlexandreSi
6113837e9f Add a structure to leaderboard provider 2022-04-06 14:39:22 +02:00
AlexandreSi
43b39880d0 Purge navigation when doing important changes to leaderboard 2022-04-06 14:39:22 +02:00
AlexandreSi
62e96826eb Implement basic navigation for leaderboard entries 2022-04-06 14:39:22 +02:00
AlexandreSi
c9edc1cddc Display leaderboard entries as small rows 2022-04-06 14:39:22 +02:00
AlexandreSi
589c971e88 Add possibility to use uri returned by API to fetch next entries 2022-04-06 14:39:22 +02:00
AlexandreSi
b32204fa51 Rename WrappedError with CenteredError 2022-04-06 14:39:22 +02:00
AlexandreSi
7146a7aafc Use spacer for margin setting 2022-04-06 14:39:22 +02:00
AlexandreSi
9f052162e2 Fix typo 2022-04-06 14:39:22 +02:00
AlexandreSi
0f217c9672 Add context to console errors 2022-04-06 14:39:22 +02:00
AlexandreSi
cc5d6fc69f Improve usability of autocomplete 2022-04-06 14:39:22 +02:00
AlexandreSi
859dd4e2dd Use reducer to store leaderboard provider state 2022-04-06 14:39:22 +02:00
AlexandreSi
ffcff4fc94 Add live url for play api 2022-04-06 14:39:22 +02:00
AlexandreSi
65342213d9 Remove warnings about having div inside p's because of alert message inside error placeholder 2022-04-06 14:39:22 +02:00
AlexandreSi
b930c35b49 Add kind props to ErrorPlaceholder to change font color according to theme 2022-04-06 14:39:22 +02:00
AlexandreSi
b65215a319 When changing the current leaderboard, define the display only best entry parameter of the fetch entries pai call according to leaderboard configuration 2022-04-06 14:39:22 +02:00
AlexandreSi
ce4fb8ae94 Add field to configure leaderboard playerUnicityDisplayChoice parameter 2022-04-06 14:39:22 +02:00
AlexandreSi
828f634964 Allow to specify select field font size to match body2 variant size 2022-04-06 14:39:22 +02:00
AlexandreSi
529e62d67d Display error for each api call in leaderboard admin 2022-04-06 14:39:22 +02:00
AlexandreSi
81080acb28 Disable actions in leaderboard entries table 2022-04-06 14:39:22 +02:00
AlexandreSi
14b8e530d9 Wait for leaderboards to have been re-fetched before continuing after update 2022-04-06 14:39:22 +02:00
AlexandreSi
55ece2fa9c Add text ellipsis on player name when too long 2022-04-06 14:39:22 +02:00
AlexandreSi
253723b7a5 Disable autoselect on leaderboard name autocomplete 2022-04-06 14:39:22 +02:00
AlexandreSi
b3ebeb4f7b Display error message when trying to unregister a game that hase leaderboard live 2022-04-06 14:39:22 +02:00
AlexandreSi
73c3990345 Focus text field after clicking on Edit icon 2022-04-06 14:37:06 +02:00
AlexandreSi
a00eb48f4a Display leaderboard entries table row header even if no entries 2022-04-06 14:37:06 +02:00
AlexandreSi
27aa7a37b3 Improve user experience with leaderboard's name text field 2022-04-06 14:37:06 +02:00
AlexandreSi
18dbbc1991 Add maxLength props to text field input 2022-04-06 14:37:06 +02:00
AlexandreSi
29bfda517c Sort fetched leaderboards by their name 2022-04-06 14:37:06 +02:00
AlexandreSi
acd04b8119 Make profile dialog full height since its 2 tabs take full height 2022-04-06 14:37:06 +02:00
AlexandreSi
7414826a39 Add leaderboard button in game card and fine tune display 2022-04-06 14:37:06 +02:00
AlexandreSi
7e062a0f41 Add props to display dialog body in flex column 2022-04-06 14:37:06 +02:00
AlexandreSi
1448a55bf8 Add Loading state to empty placeholder component 2022-04-06 14:37:06 +02:00
AlexandreSi
27ddd7fc08 Disable actions when creating new leaderboard 2022-04-06 14:37:06 +02:00
AlexandreSi
5b6ea4a1f5 Set entries to null before fetching them 2022-04-06 14:37:06 +02:00
AlexandreSi
0ea0864657 Improve leaderboard admin UI 2022-04-06 14:37:06 +02:00
AlexandreSi
1ca9158bb6 Add possibility to delete a leaderboard 2022-04-06 14:37:06 +02:00
AlexandreSi
51a1dfb3e9 Improve display of leaderboard information 2022-04-06 14:37:06 +02:00
AlexandreSi
c9b5b45ba9 Set and fetch entries after and before main operations to display loaders 2022-04-06 14:37:06 +02:00
AlexandreSi
a0a702be1a Add button to refresh entries 2022-04-06 14:37:06 +02:00
AlexandreSi
1a252c7524 Add switch to display only player best entry 2022-04-06 14:37:06 +02:00
AlexandreSi
5c4e7ab284 Add possibility to delete an entry from the list display 2022-04-06 14:37:06 +02:00
AlexandreSi
6761cafdd3 Display leaderboard entries in right part of dialog 2022-04-06 14:37:06 +02:00
AlexandreSi
7fbe1e5e0a Add possibility to reset leaderboard by clicking on the dedicated icon 2022-04-06 14:37:06 +02:00
AlexandreSi
d234124564 Change leaderboard sort order on dedicated icon click 2022-04-06 14:37:06 +02:00
AlexandreSi
78887d0869 Add options on autocomplete 2022-04-06 14:37:06 +02:00
AlexandreSi
186789ea88 Add possibility to disable closing dames details dialog and use it in leaderboard admin 2022-04-06 14:37:06 +02:00
AlexandreSi
3396e0dce2 Create leaderboard admin component and use it in the game dashboard dialog 2022-04-06 14:37:06 +02:00
AlexandreSi
251c4abd12 Initialize leaderboard context 2022-04-06 14:36:31 +02:00
AlexandreSi
6ac86005f0 Initialize play api client 2022-04-06 14:36:31 +02:00
AlexandreSi
70d7d89c02 Allow async onClick callbacks for IconButton component 2022-04-06 14:36:31 +02:00
AlexandreSi
c386547ba4 Make help page path optional in empty place holder as in help button 2022-04-06 14:36:31 +02:00
41 changed files with 3681 additions and 250 deletions

View File

@@ -9,6 +9,7 @@
#if defined(GD_IDE_ONLY)
#include <map>
#include <memory>
#include "GDCore/String.h"
namespace gd {
class Project;
@@ -196,7 +197,8 @@ class GD_CORE_API ParameterMetadata {
parameterType == "objectPointName" ||
parameterType == "objectAnimationName" ||
parameterType == "functionParameterName" ||
parameterType == "externalLayoutName";
parameterType == "externalLayoutName" ||
parameterType == "leaderboardId";
} else if (type == "variable") {
return parameterType == "objectvar" || parameterType == "globalvar" ||
parameterType == "scenevar";

View File

@@ -1,39 +1,40 @@
describe('Inventory', function () {
var runtimeGame = new gdjs.RuntimeGame({
variables: [],
properties: { windowWidth: 800, windowHeight: 600 },
resources: { resources: [] },
it('Inventories can be serialized then unserialized with no data loss', () => {
var runtimeGame = new gdjs.RuntimeGame({
variables: [],
properties: { windowWidth: 800, windowHeight: 600 },
resources: { resources: [] },
});
var runtimeScene = new gdjs.RuntimeScene(runtimeGame);
gdjs.evtTools.inventory.add(runtimeScene, 'MyInventory', 'sword');
gdjs.evtTools.inventory.add(runtimeScene, 'MyInventory', 'sword');
gdjs.evtTools.inventory.equip(runtimeScene, 'MyInventory', 'sword', true);
gdjs.evtTools.inventory.add(runtimeScene, 'MyInventory', 'armor');
gdjs.evtTools.inventory.setMaximum(runtimeScene, 'MyInventory', 'armor', 1);
var variable = new gdjs.Variable();
gdjs.evtTools.inventory.serializeToVariable(
runtimeScene,
'MyInventory',
variable
);
gdjs.evtTools.inventory.unserializeFromVariable(
runtimeScene,
'MyInventory2',
variable
);
expect(
gdjs.evtTools.inventory.count(runtimeScene, 'MyInventory2', 'sword')
).to.be(2);
expect(
gdjs.evtTools.inventory.isEquipped(runtimeScene, 'MyInventory2', 'sword')
).to.be(true);
expect(
gdjs.evtTools.inventory.count(runtimeScene, 'MyInventory2', 'armor')
).to.be(1);
expect(
gdjs.evtTools.inventory.add(runtimeScene, 'MyInventory2', 'armor')
).to.be(false);
});
var runtimeScene = new gdjs.RuntimeScene(runtimeGame);
gdjs.evtTools.inventory.add(runtimeScene, 'MyInventory', 'sword');
gdjs.evtTools.inventory.add(runtimeScene, 'MyInventory', 'sword');
gdjs.evtTools.inventory.equip(runtimeScene, 'MyInventory', 'sword', true);
gdjs.evtTools.inventory.add(runtimeScene, 'MyInventory', 'armor');
gdjs.evtTools.inventory.setMaximum(runtimeScene, 'MyInventory', 'armor', 1);
var variable = new gdjs.Variable();
gdjs.evtTools.inventory.serializeToVariable(
runtimeScene,
'MyInventory',
variable
);
gdjs.evtTools.inventory.unserializeFromVariable(
runtimeScene,
'MyInventory2',
variable
);
expect(
gdjs.evtTools.inventory.count(runtimeScene, 'MyInventory2', 'sword')
).to.be(2);
expect(
gdjs.evtTools.inventory.isEquipped(runtimeScene, 'MyInventory2', 'sword')
).to.be(true);
expect(
gdjs.evtTools.inventory.count(runtimeScene, 'MyInventory2', 'armor')
).to.be(1);
expect(
gdjs.evtTools.inventory.add(runtimeScene, 'MyInventory2', 'armor')
).to.be(false);
});

View File

@@ -0,0 +1,262 @@
// @flow
/**
* This is a declaration of an extension for GDevelop 5.
*
* Changes in this file are watched and automatically imported if the editor
* is running. You can also manually run `node import-GDJS-Runtime.js` (in newIDE/app/scripts).
*
* The file must be named "JsExtension.js", otherwise GDevelop won't load it.
* ⚠️ If you make a change and the extension is not loaded, open the developer console
* and search for any errors.
*
* More information on https://github.com/4ian/GDevelop/blob/master/newIDE/README-extensions.md
*/
/*::
// Import types to allow Flow to do static type checking on this file.
// Extensions declaration are typed using Flow (like the editor), but the files
// for the game engine are checked with TypeScript annotations.
import { type ObjectsRenderingService, type ObjectsEditorService } from '../JsExtensionTypes.flow.js'
*/
module.exports = {
createExtension: function (
_ /*: (string) => string */,
gd /*: libGDevelop */
) {
const extension = new gd.PlatformExtension();
extension
.setExtensionInformation(
'Leaderboards',
_('Leaderboards'),
_('Allow your game to send scores to your leaderboards.'),
'Florian Rival',
'Open source (MIT License)'
)
.setCategory('Leaderboards');
extension
.addInstructionOrExpressionGroupMetadata(_('Leaderboards'))
.setIcon('JsPlatform/Extensions/leaderboard.svg');
extension
.addAction(
'SavePlayerScore',
_('Save player score'),
_("Save the player's score to the given leaderboard."),
_(
'Send to leaderboard _PARAM1_ the score _PARAM2_ with player name: _PARAM3_.'
),
_('Save score'),
'JsPlatform/Extensions/leaderboard.svg',
'JsPlatform/Extensions/leaderboard.svg'
)
.addCodeOnlyParameter('currentScene', '')
.addParameter('leaderboardId', _('Leaderboard'), '', false)
.addParameter(
'expression',
_('Score to register for the player'),
'',
false
)
.addParameter('string', _('Name to register for the player'), '', false)
.addParameter(
'scenevar',
_('Variable where to store the saved score (optional)'),
'',
true
)
.addParameter(
'scenevar',
_(
'Variable where to store the error message (optional, if an error occurs)'
),
'',
true
)
.getCodeExtraInformation()
.setIncludeFile('Extensions/Leaderboards/leaderboardstools.js')
.setFunctionName('gdjs.evtTools.leaderboards.savePlayerScore');
extension
.addCondition(
'HasLastSaveErrored',
_('Last score save has errored'),
_('Check if the last attempt to save a score has errored.'),
_('Last score save in leaderboard _PARAM0_ has errored'),
_('Save score'),
'JsPlatform/Extensions/leaderboard.svg',
'JsPlatform/Extensions/leaderboard.svg'
)
.addParameter('leaderboardId', _('Leaderboard'), '', true)
.setParameterLongDescription(
_(
'If no leaderboard is specified, will return the value related to the last leaderboard save action.'
)
)
.getCodeExtraInformation()
.setIncludeFile('Extensions/Leaderboards/leaderboardstools.js')
.setFunctionName('gdjs.evtTools.leaderboards.hasSavingErrored');
extension
.addCondition(
'HasLastSaveSucceeded',
_('Last score save has succeeded'),
_('Check if the last attempt to save a score has succeeded.'),
_('Last score save in leaderboard _PARAM0_ has succeeded'),
_('Save score'),
'JsPlatform/Extensions/leaderboard.svg',
'JsPlatform/Extensions/leaderboard.svg'
)
.addParameter('leaderboardId', _('Leaderboard'), '', true)
.setParameterLongDescription(
_(
'If no leaderboard is specified, will return the value related to the last leaderboard save action that successfully ended.'
)
)
.getCodeExtraInformation()
.setIncludeFile('Extensions/Leaderboards/leaderboardstools.js')
.setFunctionName('gdjs.evtTools.leaderboards.hasBeenSaved');
extension
.addCondition(
'IsSaving',
_('Score is saving'),
_('Check if a score is currently being saved in leaderboard.'),
_('Score is saving in leaderboard _PARAM0_'),
_('Save score'),
'JsPlatform/Extensions/leaderboard.svg',
'JsPlatform/Extensions/leaderboard.svg'
)
.addParameter('leaderboardId', _('Leaderboard'), '', true)
.setParameterLongDescription(
_(
'If no leaderboard is specified, will return the value related to the last leaderboard save action.'
)
)
.getCodeExtraInformation()
.setIncludeFile('Extensions/Leaderboards/leaderboardstools.js')
.setFunctionName('gdjs.evtTools.leaderboards.isSaving');
extension
.addStrExpression(
'LastSaveError',
_('Error of last save attempt'),
_('Get the error of the last save attempt.'),
_('Error of last save attempt in leaderboard _PARAM0_'),
'JsPlatform/Extensions/leaderboard.svg'
)
.addParameter('leaderboardId', _('Leaderboard'), '', true)
.setParameterLongDescription(
_(
'If no leaderboard is specified, will return the value related to the last leaderboard save action.'
)
)
.getCodeExtraInformation()
.setIncludeFile('Extensions/Leaderboards/leaderboardstools.js')
.setFunctionName('gdjs.evtTools.leaderboards.getLastSaveError');
extension
.addCondition(
'IsLeaderboardViewErrored',
_('Leaderboard display has errored'),
_('Check if the display of the leaderboard errored.'),
_('Leaderboard display has errored'),
_('Display leaderboard'),
'JsPlatform/Extensions/leaderboard.svg',
'JsPlatform/Extensions/leaderboard.svg'
)
.getCodeExtraInformation()
.setIncludeFile('Extensions/Leaderboards/leaderboardstools.js')
.setFunctionName('gdjs.evtTools.leaderboards.isLeaderboardViewErrored');
extension
.addCondition(
'IsLeaderboardViewLoaded',
_('Leaderboard display has loaded'),
_(
'Check if the display of the leaderboard has finished loading and been displayed on screen.'
),
_('Leaderboard display has loaded and is displayed on screen'),
_('Display leaderboard'),
'JsPlatform/Extensions/leaderboard.svg',
'JsPlatform/Extensions/leaderboard.svg'
)
.getCodeExtraInformation()
.setIncludeFile('Extensions/Leaderboards/leaderboardstools.js')
.setFunctionName('gdjs.evtTools.leaderboards.isLeaderboardViewLoaded');
extension
.addCondition(
'IsLeaderboardViewLoading',
_('Leaderboard display is loading'),
_('Check if the display of the leaderboard is loading.'),
_('Leaderboard display is loading'),
_('Display leaderboard'),
'JsPlatform/Extensions/leaderboard.svg',
'JsPlatform/Extensions/leaderboard.svg'
)
.getCodeExtraInformation()
.setIncludeFile('Extensions/Leaderboards/leaderboardstools.js')
.setFunctionName('gdjs.evtTools.leaderboards.isLeaderboardViewLoading');
extension
.addStrExpression(
'FormatPlayerName',
_('Format player name'),
_('Formats a name so that it can be submitted to a leaderboard.'),
_('Save score'),
'JsPlatform/Extensions/leaderboard.svg'
)
.addParameter('string', _('Raw player name'), '', false)
.getCodeExtraInformation()
.setIncludeFile('Extensions/Leaderboards/leaderboardstools.js')
.setFunctionName('gdjs.evtTools.leaderboards.formatPlayerName');
extension
.addAction(
'DisplayLeaderboard',
_('Display leaderboard'),
_(
'Display the specified leaderboard on top of the game. If a leaderboard was already displayed on top of the game, the new leaderboard will replace it.'
),
_('Display leaderboard _PARAM1_ (display a loader: _PARAM2_)'),
_('Display leaderboard'),
'JsPlatform/Extensions/leaderboard.svg',
'JsPlatform/Extensions/leaderboard.svg'
)
.addCodeOnlyParameter('currentScene', '')
.addParameter('leaderboardId', _('Leaderboard'), '', false)
.addParameter(
'yesorno',
_('Display loader while leaderboard is loading'),
'',
false
)
.getCodeExtraInformation()
.setIncludeFile('Extensions/Leaderboards/leaderboardstools.js')
.setFunctionName('gdjs.evtTools.leaderboards.displayLeaderboard');
extension
.addAction(
'CloseLeaderboardView',
_('Close current leaderboard'),
_('Close the leaderboard currently displayed on top of the game.'),
_('Close current leaderboard displayed on top of the game'),
_('Display leaderboard'),
'JsPlatform/Extensions/leaderboard.svg',
'JsPlatform/Extensions/leaderboard.svg'
)
.addCodeOnlyParameter('currentScene', '')
.getCodeExtraInformation()
.setIncludeFile('Extensions/Leaderboards/leaderboardstools.js')
.setFunctionName('gdjs.evtTools.leaderboards.closeLeaderboardView');
return extension;
},
runExtensionSanityTests: function (
gd /*: libGDevelop */,
extension /*: gdPlatformExtension*/
) {
return [];
},
};

View File

@@ -0,0 +1,645 @@
namespace gdjs {
const logger = new gdjs.Logger('Leaderboards');
export namespace evtTools {
export namespace leaderboards {
// Score saving
class ScoreSavingState {
lastScoreSavingStartedAt: number | null;
lastScoreSavingSucceededAt: number | null;
currentlySavingScore: number | null;
currentlySavingPlayerName: string | null;
lastSavedScore: number | null;
lastSavedPlayerName: string | null;
lastSaveError: string | null;
isScoreSaving: boolean;
hasScoreBeenSaved: boolean;
hasScoreSavingErrored: boolean;
constructor() {
this.lastScoreSavingStartedAt = null;
this.lastScoreSavingSucceededAt = null;
this.currentlySavingScore = null;
this.currentlySavingPlayerName = null;
this.lastSavedScore = null;
this.lastSavedPlayerName = null;
this.lastSaveError = null;
this.isScoreSaving = false;
this.hasScoreBeenSaved = false;
this.hasScoreSavingErrored = false;
}
isSameAsLastScore(playerName: string, score: number): boolean {
return (
this.lastSavedPlayerName === playerName &&
this.lastSavedScore === score
);
}
isAlreadySavingThisScore(playerName: string, score: number): boolean {
return (
this.isScoreSaving &&
this.currentlySavingPlayerName === playerName &&
this.currentlySavingScore === score
);
}
isTooSoonToSaveAnotherScore(): boolean {
return (
!!this.lastScoreSavingSucceededAt &&
Date.now() - this.lastScoreSavingSucceededAt < 500
);
}
startSaving(playerName: string, score: number): void {
this.lastScoreSavingStartedAt = Date.now();
this.isScoreSaving = true;
this.hasScoreBeenSaved = false;
this.hasScoreSavingErrored = false;
this.currentlySavingScore = score;
this.currentlySavingPlayerName = playerName;
}
closeSaving(): void {
this.lastScoreSavingSucceededAt = Date.now();
this.lastSavedScore = this.currentlySavingScore;
this.lastSavedPlayerName = this.currentlySavingPlayerName;
this.isScoreSaving = false;
this.hasScoreBeenSaved = true;
}
setError(errorCode: string): void {
this.lastSaveError = errorCode;
this.isScoreSaving = false;
this.hasScoreBeenSaved = false;
this.hasScoreSavingErrored = true;
}
}
let _scoreSavingStateByLeaderboard: {
[leaderboardId: string]: ScoreSavingState;
} = {};
// Leaderboard display
let _requestedLeaderboardId: string | null;
let _leaderboardViewIframe: HTMLIFrameElement | null = null;
let _leaderboardViewIframeErrored: boolean = false;
let _leaderboardViewIframeLoading: boolean = false;
let _leaderboardViewIframeLoaded: boolean = false;
let _errorTimeoutId: NodeJS.Timeout | null = null;
let _leaderboardViewClosingCallback:
| ((event: MessageEvent) => void)
| null = null;
const _loaderContainer: HTMLDivElement = document.createElement('div');
_loaderContainer.style.backgroundColor = '#000000';
_loaderContainer.style.display = 'flex';
_loaderContainer.style.height = '100%';
_loaderContainer.style.width = '100%';
_loaderContainer.style.justifyContent = 'center';
_loaderContainer.style.alignItems = 'center';
const _loader = document.createElement('img');
_loader.setAttribute('width', '50px');
_loader.setAttribute(
'src',
'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9Im5vbmUiIHZpZXdCb3g9IjAgMCAyNCAyNCI+CjxjaXJjbGUgb3BhY2l0eT0nMC4yNScgY3g9IjEyIiBjeT0iMTIiIHI9IjEwIiBzdHJva2U9IiNGRkZGRkYiIHN0cm9rZS13aWR0aD0iNCI+PC9jaXJjbGU+CjxwYXRoIG9wYWNpdHk9JzAuNzUnIGZpbGw9IiNGRkZGRkYiIGQ9Ik00IDEyYTggOCAwIDAxOC04VjBDNS4zNzMgMCAwIDUuMzczIDAgMTJoNHptMiA1LjI5MUE3Ljk2MiA3Ljk2MiAwIDAxNCAxMkgwYzAgMy4wNDIgMS4xMzUgNS44MjQgMyA3LjkzOGwzLTIuNjQ3eiI+PC9wYXRoPgo8L3N2Zz4='
);
try {
_loader.animate(
[{ transform: 'rotate(0deg)' }, { transform: 'rotate(359deg)' }],
{
duration: 3000,
iterations: Infinity,
}
);
} catch {
logger.warn('Animation not supported, loader will be fixed.');
}
_loaderContainer.appendChild(_loader);
const getLastScoreSavingState = function ({
hasSucceeded,
}: {
hasSucceeded: boolean;
}): ScoreSavingState | null {
const getDateField = (scoreSavingState: ScoreSavingState) =>
hasSucceeded
? scoreSavingState.lastScoreSavingSucceededAt
: scoreSavingState.lastScoreSavingStartedAt;
const scoreSavingStates = Object.values(
_scoreSavingStateByLeaderboard
).filter((scoreSavingState) => !!getDateField(scoreSavingState));
if (scoreSavingStates.length === 0) return null;
let lastScoreSavingState = scoreSavingStates[0];
scoreSavingStates.forEach((scoreSavingState) => {
const currentItemDate = getDateField(scoreSavingState);
const lastItemDate = getDateField(lastScoreSavingState);
if (
currentItemDate &&
lastItemDate &&
currentItemDate > lastItemDate
) {
lastScoreSavingState = scoreSavingState;
}
});
return lastScoreSavingState;
};
export const savePlayerScore = function (
runtimeScene: gdjs.RuntimeScene,
leaderboardId: string,
score: float,
playerName: string,
responseVar: gdjs.Variable,
errorVar: gdjs.Variable
) {
let scoreSavingState: ScoreSavingState;
if (_scoreSavingStateByLeaderboard[leaderboardId]) {
scoreSavingState = _scoreSavingStateByLeaderboard[leaderboardId];
if (scoreSavingState.isAlreadySavingThisScore(playerName, score)) {
logger.warn(
'There is already a request to save with this player name and this score. Ignoring this one.'
);
return;
}
if (scoreSavingState.isSameAsLastScore(playerName, score)) {
logger.warn(
'The player and score to be sent are the same as previous one. Ignoring this one.'
);
const errorCode = 'SAME_AS_PREVIOUS';
scoreSavingState.setError(errorCode);
errorVar.setString(errorCode);
return;
}
if (scoreSavingState.isTooSoonToSaveAnotherScore()) {
logger.warn(
'Last entry was sent too little time ago. Ignoring this one.'
);
const errorCode = 'TOO_FAST';
scoreSavingState.setError(errorCode);
errorVar.setString(errorCode);
return;
}
} else {
scoreSavingState = new ScoreSavingState();
_scoreSavingStateByLeaderboard[leaderboardId] = scoreSavingState;
}
errorVar.setString('');
responseVar.setString('');
scoreSavingState.startSaving(playerName, score);
const baseUrl = 'https://api.gdevelop-app.com/play';
const game = runtimeScene.getGame();
const formattedPlayerName = formatPlayerName(playerName);
const sessionId = game.getSessionId();
const playerId = game.getPlayerId();
const location =
typeof window !== 'undefined' && (window as any).location
? (window as any).location.href
: '';
fetch(
`${baseUrl}/game/${gdjs.projectData.properties.projectUuid}/leaderboard/${leaderboardId}/entry`,
{
body: JSON.stringify({
playerName: formattedPlayerName,
score: score,
sessionId: sessionId,
clientPlayerId: playerId,
location: location,
}),
method: 'POST',
headers: { 'Content-Type': 'application/json' },
}
).then(
(response) => {
if (!response.ok) {
const errorCode = response.status.toString();
logger.error(
'Server responded with an error:',
errorCode,
response.statusText
);
scoreSavingState.setError(errorCode);
errorVar.setString(errorCode);
return;
}
scoreSavingState.closeSaving();
return response.text().then(
(text) => {
responseVar.setString(text);
},
(error) => {
logger.warn(
'An error occurred when reading response but score has been saved:',
error
);
responseVar.setString('CANNOT_READ_RESPONSE');
}
);
},
(error) => {
logger.error('Error while submitting a leaderboard score:', error);
const errorCode = 'REQUEST_NOT_SENT';
scoreSavingState.setError(errorCode);
errorVar.setString(errorCode);
}
);
};
export const isSaving = function (leaderboardId?: string): boolean {
if (leaderboardId) {
return _scoreSavingStateByLeaderboard[leaderboardId]
? _scoreSavingStateByLeaderboard[leaderboardId].isScoreSaving
: false;
}
const lastScoreSavingState = getLastScoreSavingState({
hasSucceeded: false,
});
return lastScoreSavingState
? lastScoreSavingState.isScoreSaving
: false;
};
export const hasBeenSaved = function (leaderboardId?: string): boolean {
if (leaderboardId) {
return _scoreSavingStateByLeaderboard[leaderboardId]
? _scoreSavingStateByLeaderboard[leaderboardId].hasScoreBeenSaved
: false;
}
const lastScoreSavingState = getLastScoreSavingState({
hasSucceeded: true,
});
return lastScoreSavingState
? lastScoreSavingState.hasScoreBeenSaved
: false;
};
export const hasSavingErrored = function (
leaderboardId?: string
): boolean {
if (leaderboardId) {
return _scoreSavingStateByLeaderboard[leaderboardId]
? _scoreSavingStateByLeaderboard[leaderboardId]
.hasScoreSavingErrored
: false;
}
const lastScoreSavingState = getLastScoreSavingState({
hasSucceeded: false,
});
return lastScoreSavingState
? lastScoreSavingState.hasScoreSavingErrored
: false;
};
export const getLastSaveError = function (
leaderboardId?: string
): string | null {
if (leaderboardId) {
return _scoreSavingStateByLeaderboard[leaderboardId]
? _scoreSavingStateByLeaderboard[leaderboardId].lastSaveError
: 'NO_DATA_ERROR';
}
const lastScoreSavingState = getLastScoreSavingState({
hasSucceeded: false,
});
return lastScoreSavingState
? lastScoreSavingState.lastSaveError
: 'NO_DATA_ERROR';
};
export const formatPlayerName = function (rawName: string): string {
if (
!rawName ||
typeof rawName !== 'string' ||
(typeof rawName === 'string' && rawName.length === 0)
) {
return `Player${Math.round(
(Math.random() * 9 + 1) * 10000 // Number between 10,000 and 99,999
)}`;
}
return rawName
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/\s/g, '_')
.replace(/[^\w|-]/g, '')
.slice(0, 30);
};
const checkLeaderboardAvailability = function (
url: string
): Promise<boolean> {
return fetch(url, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
}).then(
(response) => {
if (!response.ok) {
logger.error(
`Error while fetching leaderboard view, server returned: ${response.status} ${response.statusText}`
);
return false;
}
return true;
},
(err) => {
logger.error('Error while fetching leaderboard view:', err);
return false;
}
);
};
const receiveMessageFromLeaderboardView = function (
runtimeScene: gdjs.RuntimeScene,
displayLoader: boolean,
event: MessageEvent
) {
switch (event.data) {
case 'closeLeaderboardView':
closeLeaderboardView(runtimeScene);
break;
case 'leaderboardViewLoaded':
if (displayLoader) {
if (_errorTimeoutId) clearTimeout(_errorTimeoutId);
displayLoaderInLeaderboardView(false, runtimeScene, {
callOnErrorIfDomElementContainerMissing: false,
});
}
if (!_leaderboardViewIframe) {
handleErrorDisplayingLeaderboard(
runtimeScene,
"The leaderboard view couldn't be found. Doing nothing."
);
return;
}
_leaderboardViewIframe.style.opacity = '1';
_leaderboardViewIframeLoaded = true;
_leaderboardViewIframeLoading = false;
break;
}
};
const handleErrorDisplayingLeaderboard = function (
runtimeScene: gdjs.RuntimeScene,
message: string
) {
logger.error(message);
_leaderboardViewIframeErrored = true;
_leaderboardViewIframeLoading = false;
closeLeaderboardView(runtimeScene);
};
const resetLeaderboardDisplayErrorTimeout = (
runtimeScene: gdjs.RuntimeScene
) => {
if (_errorTimeoutId) clearTimeout(_errorTimeoutId);
_errorTimeoutId = setTimeout(() => {
if (!_leaderboardViewIframeLoaded) {
handleErrorDisplayingLeaderboard(
runtimeScene,
'Leaderboard page did not send message in time. Closing leaderboard view.'
);
}
}, 5000);
};
const displayLoaderInLeaderboardView = function (
yesOrNo: boolean,
runtimeScene: gdjs.RuntimeScene,
options: { callOnErrorIfDomElementContainerMissing: boolean }
): boolean {
const domElementContainer = runtimeScene
.getGame()
.getRenderer()
.getDomElementContainer();
if (!domElementContainer) {
if (options.callOnErrorIfDomElementContainerMissing) {
handleErrorDisplayingLeaderboard(
runtimeScene,
"The div element covering the game couldn't be found, the leaderboard cannot be displayed."
);
}
return false;
}
if (yesOrNo) {
if (
domElementContainer.children &&
domElementContainer.children.length > 0
) {
domElementContainer.insertBefore(
_loaderContainer,
domElementContainer.children[0]
);
} else {
domElementContainer.appendChild(_loaderContainer);
}
if (_leaderboardViewIframe) {
_leaderboardViewIframe.style.opacity = '0';
}
} else {
try {
domElementContainer.removeChild(_loaderContainer);
if (_leaderboardViewIframe) {
_leaderboardViewIframe.style.opacity = '1';
}
} catch {}
}
return true;
};
const computeLeaderboardDisplayingIframe = function (
url: string
): HTMLIFrameElement {
const iframe = document.createElement('iframe');
iframe.src = url;
iframe.id = 'leaderboard-view';
iframe.style.position = 'absolute';
// To trigger iframe loading and be able to listen to its events, use `opacity: 0` instead of `visibility: hidden` or `display: none`
iframe.style.opacity = '0';
iframe.style.pointerEvents = 'all';
iframe.style.backgroundColor = '#FFFFFF';
iframe.style.top = '0px';
iframe.style.height = '100%';
iframe.style.left = '0px';
iframe.style.width = '100%';
iframe.style.border = 'none';
return iframe;
};
export const displayLeaderboard = function (
runtimeScene: gdjs.RuntimeScene,
leaderboardId: string,
displayLoader: boolean
) {
// First ensure we're not trying to display multiple times the same leaderboard (in which case
// we "de-duplicate" the request to display it).
if (leaderboardId === _requestedLeaderboardId) {
if (_leaderboardViewIframeLoading) {
logger.warn(
`Already loading the view for the requested loader (${leaderboardId}), ignoring.`
);
return;
}
if (_leaderboardViewIframeLoaded) {
logger.warn(
`Already loaded the view for the requested loader (${leaderboardId}), ignoring.`
);
return;
}
}
// We are now assured we want to display a new (or different) leaderboard: start loading it.
_requestedLeaderboardId = leaderboardId;
_leaderboardViewIframeErrored = false;
_leaderboardViewIframeLoaded = false;
_leaderboardViewIframeLoading = true;
if (displayLoader) {
displayLoaderInLeaderboardView(true, runtimeScene, {
callOnErrorIfDomElementContainerMissing: true,
});
}
const gameId = gdjs.projectData.properties.projectUuid;
const targetUrl = `https://liluo.io/games/${gameId}/leaderboard/${leaderboardId}?inGameEmbedded=true`;
checkLeaderboardAvailability(targetUrl).then(
(isAvailable) => {
if (leaderboardId !== _requestedLeaderboardId) {
logger.warn(
`Received a response for leaderboard ${leaderboardId} though the last leaderboard requested is ${_requestedLeaderboardId}, ignoring this response.`
);
return;
}
if (!isAvailable) {
handleErrorDisplayingLeaderboard(
runtimeScene,
'Leaderboard data could not be fetched. Closing leaderboard view if there is one.'
);
return;
}
if (_leaderboardViewIframe) {
resetLeaderboardDisplayErrorTimeout(runtimeScene);
if (displayLoader) {
displayLoaderInLeaderboardView(true, runtimeScene, {
callOnErrorIfDomElementContainerMissing: false,
});
}
_leaderboardViewIframe.src = targetUrl;
} else {
const domElementContainer = runtimeScene
.getGame()
.getRenderer()
.getDomElementContainer();
if (!domElementContainer) {
handleErrorDisplayingLeaderboard(
runtimeScene,
"The div element covering the game couldn't be found, the leaderboard cannot be displayed."
);
return;
}
resetLeaderboardDisplayErrorTimeout(runtimeScene);
_leaderboardViewIframe = computeLeaderboardDisplayingIframe(
targetUrl
);
if (typeof window !== 'undefined') {
_leaderboardViewClosingCallback = (event: MessageEvent) => {
receiveMessageFromLeaderboardView(
runtimeScene,
displayLoader,
event
);
};
(window as any).addEventListener(
'message',
_leaderboardViewClosingCallback,
true
);
}
domElementContainer.appendChild(_leaderboardViewIframe);
}
},
(err) => {
logger.error(err);
handleErrorDisplayingLeaderboard(
runtimeScene,
'An error occurred when fetching leaderboard data. Closing leaderboard view if there is one.'
);
return;
}
);
};
export const isLeaderboardViewErrored = function (): boolean {
return _leaderboardViewIframeErrored;
};
export const isLeaderboardViewLoaded = function (): boolean {
return _leaderboardViewIframeLoaded;
};
export const isLeaderboardViewLoading = function (): boolean {
return _leaderboardViewIframeLoading;
};
export const closeLeaderboardView = function (
runtimeScene: gdjs.RuntimeScene
) {
try {
displayLoaderInLeaderboardView(false, runtimeScene, {
callOnErrorIfDomElementContainerMissing: false,
});
if (!_leaderboardViewIframe) {
logger.info(
"The iframe displaying the current leaderboard couldn't be found, the leaderboard view must be already closed."
);
return;
}
const domElementContainer = runtimeScene
.getGame()
.getRenderer()
.getDomElementContainer();
if (!domElementContainer) {
logger.info(
"The div element covering the game couldn't be found, the leaderboard view must be already closed."
);
return;
}
if (typeof window !== 'undefined') {
(window as any).removeEventListener(
'message',
_leaderboardViewClosingCallback,
true
);
_leaderboardViewClosingCallback = null;
}
domElementContainer.removeChild(_leaderboardViewIframe);
_leaderboardViewIframe = null;
} finally {
// Don't reset the loading flag (the view of another leaderboard might be loading)
// or the error flag (we want to persist the error flag even after the view is closed),
// but reset the flag indicating the view is loaded (if it was).
_leaderboardViewIframeLoaded = false;
const gameCanvas = runtimeScene.getGame().getRenderer().getCanvas();
if (gameCanvas) gameCanvas.focus();
}
};
}
}
}

View File

@@ -0,0 +1,68 @@
// @ts-check
describe('Leaderboards', () => {
describe('formatPlayerName', () => {
it('it returns name if correct', () => {
expect(gdjs.evtTools.leaderboards.formatPlayerName('PlayerName')).to.be(
'PlayerName'
);
});
it('it returns name with underscores instead of whitespaces', () => {
expect(
gdjs.evtTools.leaderboards.formatPlayerName('My Player Name')
).to.be('My_Player_Name');
});
it("it doesn't change a name with vertical bars and hyphens", () => {
expect(gdjs.evtTools.leaderboards.formatPlayerName('Pla-yer|Name')).to.be(
'Pla-yer|Name'
);
});
it('it truncates name if longer than 30', () => {
expect(
gdjs.evtTools.leaderboards.formatPlayerName(
'aPlayerNameTh4tIsT00LongToBeSaved'
)
).to.be('aPlayerNameTh4tIsT00LongToBeSa');
});
it('it generates a predefined player name with a random number if input is void/wrong type/empty', () => {
// @ts-ignore
expect(gdjs.evtTools.leaderboards.formatPlayerName(null)).to.match(
/^Player\d{5}/
);
// @ts-ignore
expect(gdjs.evtTools.leaderboards.formatPlayerName(5)).to.match(
/^Player\d{5}/
);
// @ts-ignore
expect(gdjs.evtTools.leaderboards.formatPlayerName(undefined)).to.match(
/^Player\d{5}/
);
// @ts-ignore
expect(gdjs.evtTools.leaderboards.formatPlayerName(() => {})).to.match(
/^Player\d{5}/
);
// @ts-ignore
expect(gdjs.evtTools.leaderboards.formatPlayerName('')).to.match(
/^Player\d{5}/
);
});
it('it removes accents from latin letters', () => {
expect(gdjs.evtTools.leaderboards.formatPlayerName('plâyèrÏonisé')).to.be(
'playerIonise'
);
});
it('it removes non-accepted characters in a long name', () => {
expect(
gdjs.evtTools.leaderboards.formatPlayerName(
'aιΥÉᚱÀeThatsTooonToBeՏaѵÊĐThisPartAppears'
)
).to.be('aEAeThatsTooonToBeaEThisPartAp');
});
});
});

View File

@@ -55,6 +55,8 @@ namespace gdjs {
_scaleMode: 'linear' | 'nearest';
_pixelsRounding: boolean;
_renderer: RuntimeGameRenderer;
_sessionId: string | null;
_playerId: string | null;
//Game loop management (see startGameLoop method)
_sceneStack: SceneStack;
@@ -120,6 +122,8 @@ namespace gdjs {
? new gdjs.DebuggerClient(this)
: null;
this._isPreview = this._options.isPreview || false;
this._sessionId = null;
this._playerId = null;
}
/**
@@ -648,8 +652,7 @@ namespace gdjs {
return;
}
const baseUrl = 'https://api.gdevelop-app.com/analytics';
const playerId = this._makePlayerUuid();
let sessionId: string | null = null;
this._playerId = this._makePlayerUuid();
let lastSessionHitTime = Date.now();
fetch(baseUrl + '/session', {
method: 'POST',
@@ -659,7 +662,7 @@ namespace gdjs {
// precisely identify someone.
body: JSON.stringify({
gameId: this._data.properties.projectUuid,
playerId: playerId,
playerId: this._playerId,
game: {
name: this._data.properties.name || '',
packageName: this._data.properties.packageName || '',
@@ -691,13 +694,13 @@ namespace gdjs {
})
.then((response) => response.text())
.then((returnedSessionId) => {
sessionId = returnedSessionId;
this._sessionId = returnedSessionId;
})
.catch(() => {});
/* Ignore any error */
const sendSessionHit = () => {
if (!sessionId) {
if (!this._sessionId) {
return;
}
@@ -711,8 +714,8 @@ namespace gdjs {
baseUrl + '/session-hit',
JSON.stringify({
gameId: this._data.properties.projectUuid,
playerId: playerId,
sessionId: sessionId,
playerId: this._playerId,
sessionId: this._sessionId,
})
);
};
@@ -741,6 +744,7 @@ namespace gdjs {
}
}
this._sessionMetricsInitialized = true;
this._sessionId = this._sessionId;
}
/**
@@ -762,6 +766,14 @@ namespace gdjs {
}
}
getSessionId(): string | null {
return this._sessionId;
}
getPlayerId(): string | null {
return this._playerId;
}
/**
* Called by the game renderer when the window containing the game
* has changed size (this can result from a resize of the window,

View File

@@ -75,6 +75,7 @@ module.exports = function (config) {
'../../newIDE/app/resources/GDJS/Runtime/Extensions/LinkedObjects/linkedobjects.js',
'../../newIDE/app/resources/GDJS/Runtime/Extensions/Inventory/inventory.js',
'../../newIDE/app/resources/GDJS/Runtime/Extensions/Inventory/inventorytools.js',
'../../newIDE/app/resources/GDJS/Runtime/Extensions/Leaderboards/leaderboardstools.js',
'../../newIDE/app/resources/GDJS/Runtime/Extensions/Lighting/lightruntimeobject.js',
'../../newIDE/app/resources/GDJS/Runtime/Extensions/Lighting/lightruntimeobject-pixi-renderer.js',
'../../newIDE/app/resources/GDJS/Runtime/Extensions/Lighting/lightobstacleruntimebehavior.js',

View File

@@ -0,0 +1,7 @@
<svg width="62" height="62" viewBox="0 0 62 62" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M47.1917 9.52621C61.6331 3.903 62.0217 29.4536 45.9739 23.6472L47.1917 9.52621Z" stroke="#2B3990" stroke-width="5"/>
<path d="M14.8011 9.96676C0.361326 4.34356 -0.0272331 29.8942 16.0187 24.0878L14.8011 9.96676Z" stroke="#2B3990" stroke-width="5"/>
<path d="M18.2426 30.1975C12.1426 23.6797 13.7746 11.1717 14.4872 4.10922H32.3022H47.9793C47.9793 4.10922 49.7339 23.6185 44.2953 30.1975C38.8567 36.7765 23.1267 35.4161 18.2426 30.1975Z" fill="#27AAE1" stroke="#2B3990" stroke-width="5"/>
<path d="M31.175 35.033C27.3891 35.033 31.175 35.033 25.1917 57.6229H37.2175C31.175 35.033 34.9609 35.033 31.175 35.033Z" fill="#27AAE1" stroke="#2B3990" stroke-width="5"/>
<rect x="18.6728" y="55.1399" width="25.0635" height="6.3261" rx="3" fill="#2B3990"/>
</svg>

After

Width:  |  Height:  |  Size: 859 B

View File

@@ -343,7 +343,7 @@ export default function NewInstructionEditorDialog({
cannotBeDismissed={true}
maxWidth={false}
noMargin
flexRowBody
flexBody
fullHeight={
true /* Always use full height to avoid a very small dialog when there are not a lot of objects. */
}

View File

@@ -0,0 +1,231 @@
// @flow
import React from 'react';
import { Trans, t } from '@lingui/macro';
import { I18n } from '@lingui/react';
import OpenInNew from '@material-ui/icons/OpenInNew';
import { type ParameterInlineRendererProps } from './ParameterInlineRenderer.flow';
import {
type ParameterFieldProps,
type ParameterFieldInterface,
} from './ParameterFieldCommons';
import SelectField from '../../UI/SelectField';
import SelectOption from '../../UI/SelectOption';
import { TextFieldWithButtonLayout } from '../../UI/Layout';
import RaisedButtonWithSplitMenu from '../../UI/RaisedButtonWithSplitMenu';
import { type Leaderboard } from '../../Utils/GDevelopServices/Play';
import LeaderboardContext from '../../Leaderboard/LeaderboardContext';
import LeaderboardDialog from '../../Leaderboard/LeaderboardDialog';
import GenericExpressionField from './GenericExpressionField';
import { breakUuid } from '../../Utils/GDevelopServices/Play';
import { useOnlineStatus } from '../../Utils/OnlineStatus';
const getInlineParameterDisplayValue = (
leaderboards: ?Array<Leaderboard>,
value: string
): string => {
if (!leaderboards) return value;
const leaderboard = leaderboards.find(
leaderboard => `"${leaderboard.id}"` === value
);
return leaderboard ? leaderboard.name : value;
};
const useFetchLeaderboards = () => {
const { leaderboards, listLeaderboards } = React.useContext(
LeaderboardContext
);
const fetchLeaderboards = React.useCallback(
async () => {
await listLeaderboards();
},
[listLeaderboards]
);
React.useEffect(
() => {
if (!leaderboards) {
fetchLeaderboards();
}
},
[fetchLeaderboards, leaderboards]
);
return leaderboards;
};
export default React.forwardRef<ParameterFieldProps, ParameterFieldInterface>(
function LeaderboardIdField(props, ref) {
const isOnline = useOnlineStatus();
const leaderboards = useFetchLeaderboards();
const [isAdminOpen, setIsAdminOpen] = React.useState(false);
const inputFieldRef = React.useRef<?(GenericExpressionField | SelectField)>(
null
);
React.useImperativeHandle(ref, () => ({
focus: () => {
if (inputFieldRef.current) {
inputFieldRef.current.focus();
}
},
}));
const isCurrentValueInLeaderboardList =
leaderboards &&
!!leaderboards.find(leaderboard => `"${leaderboard.id}"` === props.value);
const [isExpressionField, setIsExpressionField] = React.useState(
!leaderboards || (!!props.value && !isCurrentValueInLeaderboardList)
);
const onChangeSelectValue = (event, value) => {
props.onChange(event.target.value);
};
const onChangeTextValue = (value: string) => {
props.onChange(value);
};
const fieldLabel = props.parameterMetadata
? props.parameterMetadata.getDescription()
: undefined;
const gameHasLeaderboards = leaderboards && leaderboards.length > 0;
const selectOptions = React.useMemo(
() =>
leaderboards && gameHasLeaderboards
? leaderboards.map(leaderboard => (
<SelectOption
key={leaderboard.id}
value={`"${leaderboard.id}"`}
primaryText={`${leaderboard.name} ${
leaderboard.id ? `(${breakUuid(leaderboard.id)})` : ''
}`}
/>
))
: [
<SelectOption
disabled
key="empty"
value="empty"
primaryText={''}
/>,
],
[leaderboards, gameHasLeaderboards]
);
return (
<I18n>
{({ i18n }) => (
<>
<TextFieldWithButtonLayout
renderTextField={() =>
!isExpressionField ? (
<SelectField
ref={inputFieldRef}
value={props.value}
onChange={onChangeSelectValue}
margin={props.isInline ? 'none' : 'dense'}
fullWidth
floatingLabelText={fieldLabel}
hintText={
gameHasLeaderboards
? props.parameterMetadata &&
props.parameterMetadata.isOptional()
? t`Choose a leaderboard (optional)`
: t`Choose a leaderboard`
: t`No leaderboards`
}
helperMarkdownText={
!gameHasLeaderboards
? i18n._(
t`There are currently no leaderboards created for this game. Open the leaderboards manager to create one.`
)
: (props.parameterMetadata &&
props.parameterMetadata.getLongDescription()) ||
null
}
>
{selectOptions}
</SelectField>
) : (
<GenericExpressionField
ref={inputFieldRef}
expressionType="string"
{...props}
onChange={onChangeTextValue}
onExtractAdditionalErrors={(
currentExpression: string,
currentExpressionNode: gdExpressionNode
) => {
if (!leaderboards) {
if (!isOnline)
return 'Unable to fetch leaderboards as you are offline.';
return 'Your game may not be registered, create one in the leaderboard manager.';
}
}}
/>
)
}
renderButton={style => (
<RaisedButtonWithSplitMenu
icon={<OpenInNew />}
style={style}
primary
onClick={() => setIsAdminOpen(true)}
buildMenuTemplate={i18n => [
{
label: isExpressionField
? i18n._(t`Select the leaderboard from a list`)
: i18n._(
t`Enter the leaderboard id as a text or an expression`
),
disabled: !leaderboards,
click: () => setIsExpressionField(!isExpressionField),
},
]}
/>
)}
/>
{isAdminOpen && !!props.project && (
<LeaderboardDialog
onClose={() => setIsAdminOpen(false)}
open={isAdminOpen}
project={props.project}
/>
)}
</>
)}
</I18n>
);
}
);
const InlineLeaderboardIdField = ({
value,
parameterMetadata,
InvalidParameterValue,
}: ParameterInlineRendererProps) => {
const leaderboards = useFetchLeaderboards();
if (!value) {
if (parameterMetadata.isOptional()) {
return (
<span>
<Trans>No leaderboard chosen</Trans>
</span>
);
} else {
return (
<InvalidParameterValue isEmpty>
<Trans>Choose a leaderboard</Trans>
</InvalidParameterValue>
);
}
}
return <span>{getInlineParameterDisplayValue(leaderboards, value)}</span>;
};
export const renderInlineLeaderboardIdField = (
props: ParameterInlineRendererProps
) => <InlineLeaderboardIdField {...props} />;

View File

@@ -56,6 +56,9 @@ import ObjectAnimationNameField from './ParameterFields/ObjectAnimationNameField
import FunctionParameterNameField from './ParameterFields/FunctionParameterNameField';
import ExternalLayoutNameField from './ParameterFields/ExternalLayoutNameField';
import { type MessageDescriptor } from '../Utils/i18n/MessageDescriptor.flow';
import LeaderboardIdField, {
renderInlineLeaderboardIdField,
} from './ParameterFields/LeaderboardIdField';
const gd: libGDevelop = global.gd;
const components = {
@@ -95,6 +98,7 @@ const components = {
objectAnimationName: ObjectAnimationNameField,
functionParameterName: FunctionParameterNameField,
externalLayoutName: ExternalLayoutNameField,
leaderboardId: LeaderboardIdField,
};
const inlineRenderers: { [string]: ParameterInlineRenderer } = {
default: renderInlineDefaultField,
@@ -109,6 +113,7 @@ const inlineRenderers: { [string]: ParameterInlineRenderer } = {
trueorfalse: renderInlineTrueFalse,
operator: renderInlineOperator,
relationalOperator: renderInlineRelationalOperator,
leaderboardId: renderInlineLeaderboardIdField,
};
const userFriendlyTypeName: { [string]: MessageDescriptor } = {
mouse: t`Mouse button`,

View File

@@ -92,6 +92,9 @@ import {
addCreateBadgePreHookIfNotClaimed,
TRIVIAL_FIRST_EVENT,
} from '../Utils/GDevelopServices/Badge';
import LeaderboardContext, {
type LeaderboardState,
} from '../Leaderboard/LeaderboardContext';
const gd: libGDevelop = global.gd;
const zoomLevel = { min: 1, max: 50 };
@@ -124,6 +127,7 @@ type ComponentProps = {|
...Props,
authenticatedUser: AuthenticatedUser,
preferences: Preferences,
leaderboardsManager: ?LeaderboardState,
|};
type State = {|
@@ -1563,11 +1567,13 @@ const EventsSheet = (props, ref) => {
const authenticatedUser = React.useContext(AuthenticatedUserContext);
const preferences = React.useContext(PreferencesContext);
const leaderboardsManager = React.useContext(LeaderboardContext);
return (
<EventsSheetComponentWithoutHandle
ref={component}
authenticatedUser={authenticatedUser}
preferences={preferences}
leaderboardsManager={leaderboardsManager}
{...props}
/>
);

View File

@@ -6,29 +6,17 @@ import * as React from 'react';
import { Column, Line, Spacer } from '../UI/Grid';
import RaisedButton from '../UI/RaisedButton';
import { getGameUrl, type Game } from '../Utils/GDevelopServices/Game';
import TimelineIcon from '@material-ui/icons/Timeline';
import PlaylistPlayIcon from '@material-ui/icons/PlaylistPlay';
import TuneIcon from '@material-ui/icons/Tune';
import { ResponsiveLineStackLayout } from '../UI/Layout';
import Window from '../Utils/Window';
import FlatButton from '../UI/FlatButton';
import { GameThumbnail } from './GameThumbnail';
type Props = {|
game: Game,
isCurrentGame: boolean,
onOpenDetails: () => void,
onOpenBuilds: () => void,
onOpenAnalytics: () => void,
onOpenGameManager: () => void,
|};
export const GameCard = ({
game,
isCurrentGame,
onOpenDetails,
onOpenBuilds,
onOpenAnalytics,
}: Props) => {
export const GameCard = ({ game, isCurrentGame, onOpenGameManager }: Props) => {
const openGameUrl = () => {
const url = getGameUrl(game);
if (!url) return;
@@ -85,25 +73,14 @@ export const GameCard = ({
>
{game.publicWebBuildId && (
<RaisedButton
label={<Trans>Open</Trans>}
label={<Trans>Open in browser</Trans>}
onClick={openGameUrl}
primary
/>
)}
<FlatButton
icon={<TuneIcon />}
label={<Trans>Details</Trans>}
onClick={onOpenDetails}
/>
<FlatButton
icon={<PlaylistPlayIcon />}
label={<Trans>Builds</Trans>}
onClick={onOpenBuilds}
/>
<FlatButton
icon={<TimelineIcon />}
label={<Trans>Analytics</Trans>}
onClick={onOpenAnalytics}
<RaisedButton
label={<Trans>Manage game</Trans>}
onClick={onOpenGameManager}
primary
/>
</ResponsiveLineStackLayout>
</CardActions>

View File

@@ -1,6 +1,5 @@
// @flow
import { Trans } from '@lingui/macro';
import { t } from '@lingui/macro';
import { Trans, t } from '@lingui/macro';
import { I18n } from '@lingui/react';
import { type I18n as I18nType } from '@lingui/core';
import * as React from 'react';
@@ -49,6 +48,7 @@ import SportsEsportsIcon from '@material-ui/icons/SportsEsports';
import SmartphoneIcon from '@material-ui/icons/Smartphone';
import Crown from '../UI/CustomSvgIcons/Crown';
import { showErrorBox, showWarningBox } from '../UI/Messages/MessageBox';
import LeaderboardAdmin from './LeaderboardAdmin';
const styles = {
tableRowStatColumn: {
@@ -56,7 +56,11 @@ const styles = {
},
};
export type GamesDetailsTab = 'details' | 'builds' | 'analytics';
export type GamesDetailsTab =
| 'details'
| 'builds'
| 'analytics'
| 'leaderboards';
type Props = {|
game: Game,
@@ -82,6 +86,11 @@ export const GameDetailsDialog = ({
const [gameRollingMetrics, setGameMetrics] = React.useState<?GameMetrics>(
null
);
const [isLoading, setIsLoading] = React.useState<boolean>(false);
const [
gameUnregisterErrorText,
setGameUnregisterErrorText,
] = React.useState<?string>(null);
const [gameRollingMetricsError, setGameMetricsError] = React.useState<?Error>(
null
);
@@ -277,18 +286,31 @@ export const GameDetailsDialog = ({
return true;
};
const unregisterGame = async () => {
const unregisterGame = async (i18n: I18nType) => {
if (!profile) return;
const { id } = profile;
setGameUnregisterErrorText(null);
setIsLoading(true);
try {
setIsGameUpdating(true);
await deleteGame(getAuthorizationHeader, id, game.id);
onGameDeleted();
} catch (error) {
console.error('Unable to delete the game:', error);
if (
error.response &&
error.response.data &&
error.response.data.code === 'game-deletion/leaderboards-exist'
) {
setGameUnregisterErrorText(
i18n._(
t`You cannot unregister a game that has active leaderboards. To delete them, go in the Leaderboards tab, and delete them one by one.`
)
);
}
} finally {
setIsGameUpdating(false);
setIsLoading(false);
}
};
@@ -341,11 +363,16 @@ export const GameDetailsDialog = ({
}
open
noMargin
onRequestClose={onClose}
flexColumnBody
fullHeight={currentTab === 'leaderboards'}
onRequestClose={() => {
if (!isLoading) onClose();
}}
maxWidth="md"
actions={[
<FlatButton
label={<Trans>Close</Trans>}
disabled={isLoading}
onClick={onClose}
key="close"
/>,
@@ -358,8 +385,12 @@ export const GameDetailsDialog = ({
<Tab label={<Trans>Details</Trans>} value="details" />
<Tab label={<Trans>Builds</Trans>} value="builds" />
<Tab label={<Trans>Analytics</Trans>} value="analytics" />
<Tab label={<Trans>Leaderboards</Trans>} value="leaderboards" />
</Tabs>
<Line>
<Line expand>
{currentTab === 'leaderboards' ? (
<LeaderboardAdmin gameId={game.id} onLoading={setIsLoading} />
) : null}
{currentTab === 'details' ? (
publicGameError ? (
<PlaceholderError onRetry={loadPublicGame}>
@@ -510,7 +541,7 @@ export const GameDetailsDialog = ({
if (!answer) return;
unregisterGame();
unregisterGame(i18n);
}}
label={<Trans>Unregister this game</Trans>}
disabled={isGameUpdating}
@@ -541,6 +572,11 @@ export const GameDetailsDialog = ({
disabled={!isGameOpenedAsProject || isGameUpdating}
/>
</Line>
{gameUnregisterErrorText ? (
<PlaceholderError kind="error">
{gameUnregisterErrorText}
</PlaceholderError>
) : null}
</ColumnStackLayout>
)
) : null}

View File

@@ -95,18 +95,10 @@ export const GamesList = (props: Props) => {
key={game.id}
isCurrentGame={!!projectUuid && game.id === projectUuid}
game={game}
onOpenDetails={() => {
onOpenGameManager={() => {
setOpenedGameInitialTab('details');
setOpenedGame(game);
}}
onOpenBuilds={() => {
setOpenedGameInitialTab('builds');
setOpenedGame(game);
}}
onOpenAnalytics={() => {
setOpenedGameInitialTab('analytics');
setOpenedGame(game);
}}
/>
))}
{openedGame && (

View File

@@ -0,0 +1,161 @@
// @flow
import { Trans, t } from '@lingui/macro';
import { I18n } from '@lingui/react';
import * as React from 'react';
import Table from '@material-ui/core/Table';
import TableBody from '@material-ui/core/TableBody';
import TableCell from '@material-ui/core/TableCell';
import TableHead from '@material-ui/core/TableHead';
import TableRow from '@material-ui/core/TableRow';
import Tooltip from '@material-ui/core/Tooltip';
import DeleteOutline from '@material-ui/icons/DeleteOutline';
import Error from '@material-ui/icons/Error';
import FirstPage from '@material-ui/icons/FirstPage';
import NavigateBefore from '@material-ui/icons/NavigateBefore';
import NavigateNext from '@material-ui/icons/NavigateNext';
import IconButton from '../../UI/IconButton';
import { Column, Line } from '../../UI/Grid';
import PlaceholderLoader from '../../UI/PlaceholderLoader';
import Text from '../../UI/Text';
import { textEllipsisStyle } from '../../UI/TextEllipsis';
import { type LeaderboardDisplayData } from '../../Utils/GDevelopServices/Play';
type Props = {|
entries: ?Array<LeaderboardDisplayData>,
onDeleteEntry: (entryId: string) => Promise<void>,
isLoading: boolean,
erroredEntry?: {| entryId: string, message: React.Node |},
navigation: {|
goToFirstPage: ?() => Promise<void>,
goToPreviousPage: ?() => Promise<void>,
goToNextPage: ?() => Promise<void>,
|},
|};
const LeaderboardEntriesTable = ({
entries,
onDeleteEntry,
isLoading,
erroredEntry,
navigation,
}: Props) => {
if (!entries) return <PlaceholderLoader />;
return (
<I18n>
{({ i18n }) => (
<Column expand justifyContent="space-between">
<Table size="small">
<TableHead>
<TableRow>
<TableCell style={{ width: '20%' }} align="center">
<Trans>Score</Trans>
</TableCell>
<TableCell style={{ width: '50%' }}>
<Trans>Player</Trans>
</TableCell>
<TableCell style={{ width: '15%' }}>
<Trans>Date</Trans>
</TableCell>
<TableCell style={{ width: '15%' }}>
<Trans>Action</Trans>
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{entries.map(entry => (
<TableRow key={entry.id}>
<TableCell align="center">{entry.score}</TableCell>
<TableCell
style={{
...textEllipsisStyle,
maxWidth: 0, // to trigger the text ellipsis when overflowing
}}
>
<Tooltip title={entry.playerName}>
<span>{entry.playerName}</span>
</Tooltip>
</TableCell>
<TableCell>
<Tooltip
title={i18n.date(entry.createdAt, {
dateStyle: 'short',
timeStyle: 'short',
})}
>
<span>{i18n.date(entry.createdAt)}</span>
</Tooltip>
</TableCell>
<TableCell>
<Line>
<Tooltip title={'Remove entry'}>
<IconButton
size="small"
onClick={() => onDeleteEntry(entry.id)}
disabled={isLoading}
>
<DeleteOutline size={20} />
</IconButton>
</Tooltip>
{erroredEntry && erroredEntry.entryId === entry.id ? (
<Tooltip title={erroredEntry.message}>
<IconButton
size="small"
onClick={() => {}} // wrap in icon button to match above icon padding
>
<Error size={20} color="error" />
</IconButton>
</Tooltip>
) : null}
</Line>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{entries.length === 0 ? (
<Column expand justifyContent="center" alignItems="center">
<Text size="body2">
<Trans>No entries</Trans>
</Text>
</Column>
) : (
<Line noMargin justifyContent="flex-end">
<IconButton
tooltip={t`Go to first page`}
disabled={!navigation.goToFirstPage}
onClick={() => {
if (navigation.goToFirstPage) navigation.goToFirstPage();
}}
>
<FirstPage />
</IconButton>
<IconButton
tooltip={t`Previous page`}
disabled={!navigation.goToPreviousPage}
onClick={() => {
if (navigation.goToPreviousPage)
navigation.goToPreviousPage();
}}
>
<NavigateBefore />
</IconButton>
<IconButton
tooltip={t`Next page`}
disabled={!navigation.goToNextPage}
onClick={() => {
if (navigation.goToNextPage) navigation.goToNextPage();
}}
>
<NavigateNext />
</IconButton>
</Line>
)}
</Column>
)}
</I18n>
);
};
export default LeaderboardEntriesTable;

View File

@@ -0,0 +1,895 @@
// @flow
import * as React from 'react';
import { Trans, t } from '@lingui/macro';
import { I18n } from '@lingui/react';
import { type I18n as I18nType } from '@lingui/core';
import Avatar from '@material-ui/core/Avatar';
import CircularProgress from '@material-ui/core/CircularProgress';
import Divider from '@material-ui/core/Divider';
import List from '@material-ui/core/List';
import ListItem from '@material-ui/core/ListItem';
import ListItemAvatar from '@material-ui/core/ListItemAvatar';
import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction';
import ListItemText from '@material-ui/core/ListItemText';
import Paper from '@material-ui/core/Paper';
import Switch from '@material-ui/core/Switch';
import Tooltip from '@material-ui/core/Tooltip';
import Add from '@material-ui/icons/Add';
import Save from '@material-ui/icons/Save';
import Cancel from '@material-ui/icons/Cancel';
import Edit from '@material-ui/icons/Edit';
import Label from '@material-ui/icons/Label';
import Fingerprint from '@material-ui/icons/Fingerprint';
import Update from '@material-ui/icons/Update';
import Today from '@material-ui/icons/Today';
import Sort from '@material-ui/icons/Sort';
import PeopleAlt from '@material-ui/icons/PeopleAlt';
import SwapVertical from '@material-ui/icons/SwapVert';
import Refresh from '@material-ui/icons/Refresh';
import Delete from '@material-ui/icons/Delete';
import Visibility from '@material-ui/icons/Visibility';
import VisibilityOff from '@material-ui/icons/VisibilityOff';
import Loop from '@material-ui/icons/Loop';
import Copy from '../../UI/CustomSvgIcons/Copy';
import PlaceholderLoader from '../../UI/PlaceholderLoader';
import { EmptyPlaceholder } from '../../UI/EmptyPlaceholder';
import { Column, Line, Spacer } from '../../UI/Grid';
import IconButton from '../../UI/IconButton';
import PlaceholderError from '../../UI/PlaceholderError';
import AlertMessage from '../../UI/AlertMessage';
import RaisedButton from '../../UI/RaisedButton';
import TextField from '../../UI/TextField';
import SelectField from '../../UI/SelectField';
import SelectOption from '../../UI/SelectOption';
import { useOnlineStatus } from '../../Utils/OnlineStatus';
import {
type Leaderboard,
type LeaderboardSortOption,
type LeaderboardPlayerUnicityDisplayOption,
type LeaderboardVisibilityOption,
breakUuid,
} from '../../Utils/GDevelopServices/Play';
import LeaderboardContext from '../../Leaderboard/LeaderboardContext';
import LeaderboardProvider from '../../Leaderboard/LeaderboardProvider';
import Window from '../../Utils/Window';
import LeaderboardEntriesTable from './LeaderboardEntriesTable';
import { ResponsiveLineStackLayout } from '../../UI/Layout';
import { useResponsiveWindowWidth } from '../../UI/Reponsive/ResponsiveWindowMeasurer';
import { textEllipsisStyle } from '../../UI/TextEllipsis';
import { shouldValidate } from '../../UI/KeyboardShortcuts/InteractionKeys';
import Text from '../../UI/Text';
import { GameRegistration } from '../GameRegistration';
type Props = {| onLoading: boolean => void, project?: gdProject |};
type ContainerProps = {| ...Props, gameId: string |};
type ApiError = {|
action:
| 'entriesFetching'
| 'entryDeletion'
| 'leaderboardsFetching'
| 'leaderboardNameUpdate'
| 'leaderboardSortUpdate'
| 'leaderboardPlayerUnicityDisplayChoiceUpdate'
| 'leaderboardCreation'
| 'leaderboardReset'
| 'leaderboardDeletion',
message: React.Node,
itemId?: string,
|};
const CenteredError = ({ children }: {| children: React.Node |}) => (
<Column expand justifyContent="center" alignItems="center">
<Line>{children}</Line>
</Column>
);
const styles = {
leftColumn: { display: 'flex', flexDirection: 'column', flex: 1, padding: 5 },
rightColumn: {
display: 'flex',
flexDirection: 'column',
flex: 2,
},
leaderboardConfigurationPaper: { padding: 5, margin: 5 },
leaderboardNameText: { ...textEllipsisStyle, width: 150 },
leaderboardNameTextField: { width: 125, fontSize: 14 },
};
export const LeaderboardAdmin = ({ onLoading, project }: Props) => {
const isOnline = useOnlineStatus();
const windowWidth = useResponsiveWindowWidth();
const [isEditingName, setIsEditingName] = React.useState<boolean>(false);
const [isRequestPending, setIsRequestPending] = React.useState<boolean>(
false
);
const [newName, setNewName] = React.useState<string>('');
const [newNameError, setNewNameError] = React.useState<?string>(null);
const newNameTextFieldRef = React.useRef<?TextField>(null);
const [apiError, setApiError] = React.useState<?ApiError>(null);
const [
displayGameRegistration,
setDisplayGameRegistration,
] = React.useState<boolean>(false);
const {
leaderboards,
listLeaderboards,
currentLeaderboard,
createLeaderboard,
selectLeaderboard,
updateLeaderboard,
resetLeaderboard,
deleteLeaderboard,
deleteLeaderboardEntry,
displayOnlyBestEntry,
setDisplayOnlyBestEntry,
fetchLeaderboardEntries,
browsing: { entries, goToNextPage, goToPreviousPage, goToFirstPage },
} = React.useContext(LeaderboardContext);
const setIsLoading = React.useCallback(
(yesOrNo: boolean) => {
setIsRequestPending(yesOrNo);
onLoading(yesOrNo);
},
[onLoading]
);
const onUpdateLeaderboard = async (
i18n: I18nType,
payload: {|
name?: string,
sort?: LeaderboardSortOption,
playerUnicityDisplayChoice?: LeaderboardPlayerUnicityDisplayOption,
visibility?: LeaderboardVisibilityOption,
|}
) => {
setNewNameError(null);
if (payload.name !== undefined && payload.name.length === 0) {
setNewNameError(
i18n._(
t`Please enter a name that is at least one character long and 50 at most.`
)
);
return;
}
setIsLoading(true);
setApiError(null);
try {
await updateLeaderboard(payload);
if (payload.name) setIsEditingName(false);
} catch (err) {
console.error('An error occurred when updating leaderboard', err);
setApiError({
action: payload.name
? 'leaderboardNameUpdate'
: payload.sort
? 'leaderboardSortUpdate'
: 'leaderboardPlayerUnicityDisplayChoiceUpdate',
message: payload.name ? (
<Trans>
An error occurred when updating the name of the leaderboard, please
close the dialog, come back and try again.
</Trans>
) : payload.sort ? (
<Trans>
An error occurred when updating the sort direction of the
leaderboard, please close the dialog, come back and try again.
</Trans>
) : payload.visibility ? (
<Trans>
An error occurred when updating the visibility of the leaderboard,
please close the dialog, come back and try again.
</Trans>
) : (
<Trans>
An error occurred when updating the display choice of the
leaderboard, please close the dialog, come back and try again.
</Trans>
),
});
} finally {
setIsLoading(false);
}
};
const onListLeaderboards = React.useCallback(
() => {
const fetchAndHandleError = async () => {
setIsLoading(true);
setApiError(null);
try {
await listLeaderboards();
} catch (err) {
if (err.response && err.response.status === 404) {
setDisplayGameRegistration(true);
return;
}
console.error('An error occurred when fetching leaderboards', err);
setApiError({
action: 'leaderboardsFetching',
message: (
<Trans>
An error occurred when fetching the leaderboards, please close
the dialog and reopen it.
</Trans>
),
});
} finally {
setIsLoading(false);
}
};
fetchAndHandleError();
},
[setIsLoading, listLeaderboards]
);
const onFetchLeaderboardEntries = async () => {
setIsLoading(true);
setApiError(null);
try {
await fetchLeaderboardEntries();
} catch (err) {
console.error('An error occurred when fetching leaderboard entries', err);
setApiError({
action: 'entriesFetching',
message: (
<Trans>
An error occurred when fetching the entries of the leaderboard,
please try again.
</Trans>
),
});
} finally {
setIsLoading(false);
}
};
const onCreateLeaderboard = async () => {
setIsLoading(true);
setApiError(null);
try {
await createLeaderboard({
name: 'New leaderboard',
sort: 'ASC',
});
} catch (err) {
console.error('An error occurred when creating leaderboard', err);
setApiError({
action: 'leaderboardCreation',
message: (
<Trans>
An error occurred when creating a new leaderboard, please close the
dialog, come back and try again.
</Trans>
),
});
} finally {
setIsLoading(false);
}
};
const onResetLeaderboard = async (i18n: I18nType) => {
const answer = Window.showConfirmDialog(
i18n._(
t`All current entries will be deleted, are you sure you want to reset this leaderboard? This can't be undone.`
)
);
if (!answer) return;
setIsLoading(true);
setApiError(null);
try {
await resetLeaderboard();
} catch (err) {
console.error('An error occurred when resetting leaderboard', err);
setApiError({
action: 'leaderboardReset',
message: (
<Trans>
An error occurred when resetting the leaderboard, please close the
dialog, come back and try again.
</Trans>
),
});
} finally {
setIsLoading(false);
}
};
const onDeleteLeaderboard = async (i18n: I18nType) => {
const answer = Window.showConfirmDialog(
i18n._(
t`Are you sure you want to delete this leaderboard and all of its entries? This can't be undone.`
)
);
if (!answer) return;
setIsLoading(true);
setApiError(null);
try {
await deleteLeaderboard();
} catch (err) {
console.error('An error occurred when deleting leaderboard', err);
setApiError({
action: 'leaderboardDeletion',
message: (
<Trans>
An error occurred when deleting the leaderboard, please close the
dialog, come back and try again.
</Trans>
),
});
} finally {
setIsLoading(false);
}
};
const onDeleteEntry = async (i18n: I18nType, entryId: string) => {
const answer = Window.showConfirmDialog(
i18n._(
t`Are you sure you want to delete this entry? This can't be undone.`
)
);
if (!answer) return;
setIsLoading(true);
setApiError(null);
try {
await deleteLeaderboardEntry(entryId);
} catch (err) {
console.error('An error occurred when deleting entry', err);
setApiError({
action: 'entryDeletion',
message: (
<Trans>
An error occurred when deleting the entry, please try again.
</Trans>
),
itemId: entryId,
});
} finally {
setIsLoading(false);
}
};
React.useEffect(
() => {
if (isEditingName && newNameTextFieldRef.current) {
newNameTextFieldRef.current.focus();
}
},
[isEditingName]
);
React.useEffect(
() => {
if (leaderboards === null) {
onListLeaderboards();
}
},
[leaderboards, onListLeaderboards]
);
React.useEffect(() => {
if (currentLeaderboard) onFetchLeaderboardEntries();
// This has to be executed on component mount to refresh entries on each admin opening
// eslint-disable-next-line
}, []);
const onCopy = React.useCallback(
() => {
if (!currentLeaderboard) return;
// TODO: use Clipboard.js, after it's been reworked to use this API and handle text.
navigator.clipboard.writeText(currentLeaderboard.id);
},
[currentLeaderboard]
);
if (!isOnline) {
return (
<CenteredError>
<PlaceholderError>
<Trans>
An internet connection is required to administrate your game's
leaderboards.
</Trans>
</PlaceholderError>
</CenteredError>
);
}
if (!!displayGameRegistration) {
return (
<CenteredError>
<GameRegistration
project={project}
hideIfRegistered
onGameRegistered={() => {
setDisplayGameRegistration(false);
onListLeaderboards();
}}
/>
</CenteredError>
);
}
if (apiError && apiError.action === 'leaderboardCreation') {
return (
<CenteredError>
<AlertMessage kind="error">{apiError.message}</AlertMessage>
</CenteredError>
);
}
if (apiError && apiError.action === 'leaderboardsFetching') {
return (
<CenteredError>
<PlaceholderError onRetry={onListLeaderboards} kind="error">
{apiError.message}
</PlaceholderError>
</CenteredError>
);
}
if (leaderboards === null) {
if (isRequestPending) return <PlaceholderLoader />;
return (
<CenteredError>
<PlaceholderError onRetry={onListLeaderboards} kind="error">
<Trans>
An error occurred when retrieving leaderboards, please try again
later.
</Trans>
</PlaceholderError>
</CenteredError>
);
}
if (!!leaderboards && leaderboards.length === 0)
return (
<Line noMargin expand justifyContent="center" alignItems="center">
<EmptyPlaceholder
title={<Trans>Create your game's first leaderboard</Trans>}
description={<Trans>Leaderboards help retain your players</Trans>}
actionLabel={<Trans>Create a leaderboard</Trans>}
onAdd={() => {
onCreateLeaderboard();
}}
isLoading={isRequestPending}
/>
</Line>
);
const getLeaderboardDescription = (
i18n: I18nType,
currentLeaderboard: Leaderboard
) => [
{
key: 'name',
avatar: <Label />,
text: isEditingName ? (
<Line alignItems="center" expand noMargin>
<TextField
ref={newNameTextFieldRef}
margin="none"
style={styles.leaderboardNameTextField}
maxLength={50}
value={newName}
errorText={newNameError}
onChange={(e, text) => setNewName(text)}
onKeyPress={event => {
if (shouldValidate(event) && !isRequestPending) {
onUpdateLeaderboard(i18n, { name: newName });
}
}}
disabled={isRequestPending}
/>
{!isRequestPending && (
<>
<Spacer />
<IconButton
tooltip={t`Cancel`}
style={{ padding: 0 }}
onClick={() => {
setIsEditingName(false);
}}
>
<Cancel />
</IconButton>
</>
)}
</Line>
) : (
<Tooltip title={currentLeaderboard.name}>
<Text size="body2" style={styles.leaderboardNameText}>
{currentLeaderboard.name}
</Text>
</Tooltip>
),
secondaryText:
apiError && apiError.action === 'leaderboardNameUpdate' ? (
<Text color="error" size="body2">
{apiError.message}
</Text>
) : null,
secondaryAction: (
<IconButton
onClick={() => {
if (isEditingName) {
onUpdateLeaderboard(i18n, { name: newName });
} else {
setNewName(currentLeaderboard.name);
setIsEditingName(true);
}
}}
tooltip={isEditingName ? t`Save` : t`Rename`}
disabled={isRequestPending}
edge="end"
>
{isEditingName ? (
isRequestPending ? (
<CircularProgress size={20} />
) : (
<Save />
)
) : (
<Edit />
)}
</IconButton>
),
},
{
key: 'id',
avatar: <Fingerprint />,
text: (
<Tooltip title={currentLeaderboard.id}>
<Text size="body2">{breakUuid(currentLeaderboard.id)}</Text>
</Tooltip>
),
secondaryText: null,
secondaryAction: (
<IconButton onClick={onCopy} tooltip={t`Copy`} edge="end">
<Copy />
</IconButton>
),
},
{
key: 'startDatetime',
avatar: <Today />,
text: (
<Tooltip
title={i18n._(
t`Date from which entries are taken into account: ${i18n.date(
currentLeaderboard.startDatetime,
{
dateStyle: 'short',
timeStyle: 'short',
}
)}`
)}
>
<Text size="body2">
{i18n.date(currentLeaderboard.startDatetime)}
</Text>
</Tooltip>
),
secondaryText:
apiError && apiError.action === 'leaderboardReset' ? (
<Text color="error" size="body2">
{apiError.message}
</Text>
) : null,
secondaryAction: (
<IconButton
onClick={() => onResetLeaderboard(i18n)}
tooltip={t`Reset leaderboard`}
edge="end"
disabled={isRequestPending || isEditingName}
>
<Update />
</IconButton>
),
},
{
key: 'sort',
avatar: <Sort />,
text: (
<Text size="body2">
{currentLeaderboard.sort === 'ASC' ? (
<Trans>Lower is better</Trans>
) : (
<Trans>Higher is better</Trans>
)}
</Text>
),
secondaryText:
apiError && apiError.action === 'leaderboardSortUpdate' ? (
<Text color="error" size="body2">
{apiError.message}
</Text>
) : null,
secondaryAction: (
<IconButton
onClick={async () => {
await onUpdateLeaderboard(i18n, {
sort: currentLeaderboard.sort === 'ASC' ? 'DESC' : 'ASC',
});
}}
tooltip={t`Change sort direction`}
edge="end"
disabled={isRequestPending || isEditingName}
>
<SwapVertical />
</IconButton>
),
},
{
key: 'visibility',
avatar:
currentLeaderboard.visibility === 'HIDDEN' ? (
<VisibilityOff />
) : (
<Visibility />
),
text: (
<Tooltip
title={i18n._(
currentLeaderboard.visibility === 'HIDDEN'
? t`Anyone with the link can see it, but it is not listed in your game's leaderboards.`
: t`Anyone can access it.`
)}
>
<Text size="body2">
{currentLeaderboard.visibility === 'HIDDEN' ? (
<Trans>Not visible</Trans>
) : (
<Trans>Public</Trans>
)}
</Text>
</Tooltip>
),
secondaryText:
apiError && apiError.action === 'leaderboardVisibilityUpdate' ? (
<Text color="error" size="body2">
{apiError.message}
</Text>
) : null,
secondaryAction: (
<IconButton
onClick={async () => {
await onUpdateLeaderboard(i18n, {
visibility:
currentLeaderboard.visibility === 'HIDDEN'
? 'PUBLIC'
: 'HIDDEN',
});
}}
tooltip={
currentLeaderboard.visibility === 'HIDDEN'
? t`Make the leaderboard public`
: t`Hide the leaderboard`
}
edge="end"
disabled={isRequestPending || isEditingName}
>
<Loop />
</IconButton>
),
},
{
key: 'playerUnicityDisplayChoice',
avatar: <PeopleAlt />,
text: (
<SelectField
fullWidth
margin="none"
value={currentLeaderboard.playerUnicityDisplayChoice}
onChange={(e, i, value) => {
onUpdateLeaderboard(i18n, {
// $FlowFixMe
playerUnicityDisplayChoice: value,
});
}}
disabled={isRequestPending || isEditingName}
inputStyle={{ fontSize: 14 }}
helperMarkdownText={
currentLeaderboard.playerUnicityDisplayChoice === 'FREE'
? i18n._(
t`Users can chose to see only players' best entries or not.`
)
: currentLeaderboard.playerUnicityDisplayChoice ===
'PREFER_UNIQUE'
? i18n._(t`Only player's best entries are displayed.`)
: i18n._(t`All entries are displayed.`)
}
>
<SelectOption
key={'free'}
value={'FREE'}
primaryText={i18n._(t`Let the user select`)}
/>
<SelectOption
key={'prefer-unique'}
value={'PREFER_UNIQUE'}
primaryText={i18n._(t`Only best entry`)}
/>
<SelectOption
key={'prefer-non-unique'}
value={'PREFER_NON_UNIQUE'}
primaryText={i18n._(t`All entries`)}
/>
</SelectField>
),
secondaryText:
apiError &&
apiError.action === 'leaderboardPlayerUnicityDisplayChoiceUpdate' ? (
<Text color="error" size="body2">
{apiError.message}
</Text>
) : null,
secondaryAction: null,
},
];
return (
<I18n>
{({ i18n }) => (
<ResponsiveLineStackLayout noMargin expand noColumnMargin>
<div style={styles.leftColumn}>
<Paper elevation={5} style={styles.leaderboardConfigurationPaper}>
<Column>
<Line>
{currentLeaderboard && leaderboards ? (
<SelectField
fullWidth
floatingLabelText={<Trans>Leaderboard name</Trans>}
value={currentLeaderboard.id}
onChange={(e, i, leaderboardId) => {
selectLeaderboard(leaderboardId);
}}
>
{leaderboards.map(leaderboard => (
<SelectOption
key={leaderboard.id}
value={leaderboard.id}
primaryText={leaderboard.name}
/>
))}
</SelectField>
) : null}
<IconButton
onClick={onCreateLeaderboard}
disabled={isEditingName || isRequestPending}
>
<Add />
</IconButton>
</Line>
{currentLeaderboard ? (
<>
<List>
{getLeaderboardDescription(i18n, currentLeaderboard).map(
(item, index) => (
<React.Fragment key={`fragment-${item.key}`}>
{index > 0 ? (
<Divider
key={`divider-${item.key}`}
component="li"
/>
) : null}
<ListItem key={item.key} disableGutters>
<ListItemAvatar>
<Avatar>{item.avatar}</Avatar>
</ListItemAvatar>
<ListItemText
disableTypography
secondary={item.secondaryText}
>
{item.text}
</ListItemText>
{item.secondaryAction ? (
<ListItemSecondaryAction>
{item.secondaryAction}
</ListItemSecondaryAction>
) : null}
</ListItem>
</React.Fragment>
)
)}
</List>
<Line>
<RaisedButton
icon={<Delete />}
label={<Trans>Delete</Trans>}
disabled={isRequestPending || isEditingName}
onClick={() => onDeleteLeaderboard(i18n)}
/>
</Line>
{apiError && apiError.action === 'leaderboardDeletion' ? (
<PlaceholderError kind="error">
{apiError.message}
</PlaceholderError>
) : null}
</>
) : null}
</Column>
</Paper>
</div>
<div
style={{
...styles.rightColumn,
paddingLeft: windowWidth === 'small' ? 0 : 20,
}}
>
<Line alignItems="center" justifyContent="flex-end">
<Tooltip
title={i18n._(
t`When checked, will only display the best score of each player (only for the display below).`
)}
>
<Text size="body2">
<Trans>Player best entry</Trans>
</Text>
</Tooltip>
<Switch
color="primary"
size="small"
checked={displayOnlyBestEntry}
onClick={() => setDisplayOnlyBestEntry(!displayOnlyBestEntry)}
/>
<Divider orientation="vertical" />
<IconButton
onClick={onFetchLeaderboardEntries}
disabled={isRequestPending || isEditingName}
tooltip={t`Refresh`}
size="small"
>
<Refresh />
</IconButton>
<Spacer />
</Line>
{apiError && apiError.action === 'entriesFetching' ? (
<CenteredError>
<PlaceholderError
onRetry={onFetchLeaderboardEntries}
kind="error"
>
{apiError.message}
</PlaceholderError>
</CenteredError>
) : (
<LeaderboardEntriesTable
entries={entries}
onDeleteEntry={entryId => onDeleteEntry(i18n, entryId)}
isLoading={isRequestPending || isEditingName}
navigation={{
goToNextPage,
goToPreviousPage,
goToFirstPage,
}}
erroredEntry={
apiError &&
apiError.action === 'entryDeletion' &&
apiError.itemId
? { entryId: apiError.itemId, message: apiError.message }
: undefined
}
/>
)}
</div>
</ResponsiveLineStackLayout>
)}
</I18n>
);
};
const LeaderboardAdminContainer = ({
gameId,
...otherProps
}: ContainerProps) => (
<LeaderboardProvider gameId={gameId}>
<LeaderboardAdmin {...otherProps} />
</LeaderboardProvider>
);
export default LeaderboardAdminContainer;

View File

@@ -83,6 +83,12 @@ const jsExtensions = [
extensionModule: require('GDJS-for-web-app-only/Runtime/Extensions/Firebase/JsExtension.js'),
objectsRenderingServiceModules: {},
},
{
name: 'Leaderboards',
// $FlowExpectedError - this path is ignored for Flow.
extensionModule: require('GDJS-for-web-app-only/Runtime/Extensions/Leaderboards/JsExtension.js'),
objectsRenderingServiceModules: {},
},
{
name: 'DialogueTree',
// $FlowExpectedError - this path is ignored for Flow.

View File

@@ -0,0 +1,65 @@
// @flow
import * as React from 'react';
import {
type Leaderboard,
type LeaderboardSortOption,
type LeaderboardDisplayData,
type LeaderboardPlayerUnicityDisplayOption,
type LeaderboardVisibilityOption,
} from '../Utils/GDevelopServices/Play';
export type LeaderboardState = {|
leaderboards: ?Array<Leaderboard>,
currentLeaderboard: ?Leaderboard,
displayOnlyBestEntry: boolean,
browsing: {|
entries: ?Array<LeaderboardDisplayData>,
goToNextPage: ?() => Promise<void>,
goToPreviousPage: ?() => Promise<void>,
goToFirstPage: ?() => Promise<void>,
|},
createLeaderboard: ({|
name: string,
sort: LeaderboardSortOption,
|}) => Promise<?Leaderboard>,
listLeaderboards: () => Promise<void>,
selectLeaderboard: (id: string) => void,
setDisplayOnlyBestEntry: boolean => void,
updateLeaderboard: ({|
name?: string,
sort?: LeaderboardSortOption,
playerUnicityDisplayChoice?: LeaderboardPlayerUnicityDisplayOption,
visibility?: LeaderboardVisibilityOption,
|}) => Promise<void>,
resetLeaderboard: () => Promise<void>,
deleteLeaderboard: () => Promise<void>,
deleteLeaderboardEntry: (entryId: string) => Promise<void>,
fetchLeaderboardEntries: () => Promise<void>,
|};
export const initialLeaderboardState = {
leaderboards: null,
currentLeaderboard: null,
displayOnlyBestEntry: false,
browsing: {
entries: null,
goToNextPage: null,
goToPreviousPage: null,
goToFirstPage: null,
},
createLeaderboard: async () => null,
listLeaderboards: async () => {},
selectLeaderboard: () => {},
setDisplayOnlyBestEntry: () => {},
updateLeaderboard: async () => {},
resetLeaderboard: async () => {},
deleteLeaderboard: async () => {},
deleteLeaderboardEntry: async entryId => {},
fetchLeaderboardEntries: async () => {},
};
const LeaderboardContext = React.createContext<LeaderboardState>(
initialLeaderboardState
);
export default LeaderboardContext;

View File

@@ -0,0 +1,41 @@
//@flow
import React from 'react';
import { LeaderboardAdmin } from '../GameDashboard/LeaderboardAdmin';
import Dialog from '../UI/Dialog';
import FlatButton from '../UI/FlatButton';
import { Trans } from '@lingui/macro';
type Props = {|
onClose: () => void,
open: boolean,
project: gdProject,
|};
const LeaderboardDialog = ({ onClose, open, project }: Props) => {
const [isLoading, setIsLoading] = React.useState(false);
return (
<Dialog
actions={[
<FlatButton
label={<Trans>Close</Trans>}
disabled={isLoading}
onClick={onClose}
key={'Close'}
/>,
]}
open={open}
cannotBeDismissed={true}
onRequestClose={() => {
if (!isLoading) onClose();
}}
title={<Trans>Leaderboards</Trans>}
flexBody
fullHeight
>
<LeaderboardAdmin onLoading={setIsLoading} project={project} />
</Dialog>
);
};
export default LeaderboardDialog;

View File

@@ -0,0 +1,422 @@
// @flow
import * as React from 'react';
import LeaderboardContext from './LeaderboardContext';
import {
type Leaderboard,
type LeaderboardEntry,
type LeaderboardExtremePlayerScore,
type LeaderboardSortOption,
type LeaderboardVisibilityOption,
type LeaderboardPlayerUnicityDisplayOption,
type LeaderboardDisplayData,
createLeaderboard as doCreateLeaderboard,
updateLeaderboard as doUpdateLeaderboard,
resetLeaderboard as doResetLeaderboard,
deleteLeaderboardEntry as doDeleteLeaderboardEntry,
deleteLeaderboard as doDeleteLeaderboard,
extractExtremeScoreDisplayData,
extractEntryDisplayData,
listLeaderboardEntries,
listGameLeaderboards,
} from '../Utils/GDevelopServices/Play';
import AuthenticatedUserContext from '../Profile/AuthenticatedUserContext';
type Props = {| gameId: string, children: React.Node |};
const pageSize = 10;
const shouldDisplayOnlyBestEntries = (leaderboard: Leaderboard) =>
leaderboard.playerUnicityDisplayChoice === 'PREFER_UNIQUE';
type ReducerState = {|
currentLeaderboardId: ?string,
currentLeaderboard: ?Leaderboard,
leaderboardsByIds: ?{| [string]: Leaderboard |},
displayOnlyBestEntry: boolean,
entries: ?Array<LeaderboardDisplayData>,
mapPageIndexToUri: {| [number]: string |},
pageIndex: number,
|};
type ReducerAction =
| {| type: 'SET_LEADERBOARDS', payload: ?Array<Leaderboard> |}
| {| type: 'SET_ENTRIES', payload: ?Array<LeaderboardDisplayData> |}
| {| type: 'SET_NEXT_PAGE_URI', payload: string |}
| {| type: 'SELECT_LEADERBOARD', payload: string |}
| {| type: 'SET_PAGE_INDEX', payload: number |}
| {| type: 'PURGE_NAVIGATION' |}
| {| type: 'CHANGE_DISPLAY_ONLY_BEST_ENTRY', payload: boolean |}
| {| type: 'UPDATE_OR_CREATE_LEADERBOARD', payload: Leaderboard |}
| {| type: 'REMOVE_LEADERBOARD', payload: string |};
const reducer = (state: ReducerState, action: ReducerAction): ReducerState => {
switch (action.type) {
case 'SET_LEADERBOARDS':
const leaderboards = action.payload;
if (!leaderboards)
return {
...state,
leaderboardsByIds: null,
currentLeaderboardId: null,
currentLeaderboard: null,
};
const leaderboardsByIds = leaderboards.reduce((acc, leaderboard) => {
acc[leaderboard.id] = leaderboard;
return acc;
}, {});
const shouldDefineCurrentLeaderboardIfNoneSelected =
!state.currentLeaderboard && leaderboards && leaderboards.length > 0;
const newCurrentLeaderboard = shouldDefineCurrentLeaderboardIfNoneSelected
? leaderboards[0]
: state.currentLeaderboard;
return {
...state,
leaderboardsByIds,
displayOnlyBestEntry: newCurrentLeaderboard
? shouldDisplayOnlyBestEntries(newCurrentLeaderboard)
: false,
currentLeaderboardId: newCurrentLeaderboard
? newCurrentLeaderboard.id
: null,
currentLeaderboard: newCurrentLeaderboard,
};
case 'SET_ENTRIES':
return {
...state,
entries: action.payload,
};
case 'PURGE_NAVIGATION':
return {
...state,
entries: null,
pageIndex: 0,
mapPageIndexToUri: {},
};
case 'SET_NEXT_PAGE_URI':
const nextPageIndex = state.pageIndex + 1;
return {
...state,
mapPageIndexToUri: {
...state.mapPageIndexToUri,
[nextPageIndex]: action.payload,
},
};
case 'SET_PAGE_INDEX':
return {
...state,
pageIndex: action.payload,
};
case 'SELECT_LEADERBOARD':
if (!state.leaderboardsByIds) return state;
const leaderboard = state.leaderboardsByIds[action.payload];
return {
...state,
displayOnlyBestEntry: shouldDisplayOnlyBestEntries(leaderboard),
currentLeaderboardId: leaderboard.id,
currentLeaderboard: leaderboard,
};
case 'CHANGE_DISPLAY_ONLY_BEST_ENTRY':
return {
...state,
displayOnlyBestEntry: action.payload,
};
case 'UPDATE_OR_CREATE_LEADERBOARD':
return {
...state,
displayOnlyBestEntry: shouldDisplayOnlyBestEntries(action.payload),
leaderboardsByIds: {
...state.leaderboardsByIds,
[action.payload.id]: action.payload,
},
currentLeaderboardId: action.payload.id,
currentLeaderboard: action.payload,
};
case 'REMOVE_LEADERBOARD':
const newLeaderboardsByIds = { ...state.leaderboardsByIds };
delete newLeaderboardsByIds[action.payload];
const leaderboardsIds = Object.keys(newLeaderboardsByIds);
if (leaderboardsIds.length === 0) {
return {
...state,
displayOnlyBestEntry: false,
leaderboardsByIds: newLeaderboardsByIds,
currentLeaderboard: null,
currentLeaderboardId: null,
};
}
return {
...state,
displayOnlyBestEntry: shouldDisplayOnlyBestEntries(
newLeaderboardsByIds[leaderboardsIds[0]]
),
leaderboardsByIds: newLeaderboardsByIds,
currentLeaderboard: newLeaderboardsByIds[leaderboardsIds[0]],
currentLeaderboardId: leaderboardsIds[0],
};
default: {
return { ...state };
}
}
};
const LeaderboardProvider = ({ gameId, children }: Props) => {
const authenticatedUser = React.useContext(AuthenticatedUserContext);
// Ensure that only one request for leaderboards list is sent at the same time.
const isListingLeaderboards = React.useRef(false);
const [
{
currentLeaderboardId,
currentLeaderboard,
leaderboardsByIds,
displayOnlyBestEntry,
entries,
mapPageIndexToUri,
pageIndex,
},
dispatch,
] = React.useReducer<ReducerState, ReducerAction>(reducer, {
currentLeaderboardId: null,
currentLeaderboard: null,
leaderboardsByIds: null,
displayOnlyBestEntry: false,
entries: null,
mapPageIndexToUri: {},
pageIndex: 0,
});
const listLeaderboards = React.useCallback(
async () => {
if (!isListingLeaderboards.current) {
isListingLeaderboards.current = true;
try {
dispatch({ type: 'SET_LEADERBOARDS', payload: null });
const fetchedLeaderboards = await listGameLeaderboards(
authenticatedUser,
gameId
);
if (!fetchedLeaderboards) return;
fetchedLeaderboards.sort((a, b) => a.name.localeCompare(b.name));
dispatch({
type: 'SET_LEADERBOARDS',
payload: fetchedLeaderboards,
});
} finally {
isListingLeaderboards.current = false;
}
}
},
[gameId, authenticatedUser]
);
const createLeaderboard = React.useCallback(
async (creationPayload: {|
name: string,
sort: LeaderboardSortOption,
|}) => {
dispatch({ type: 'SET_ENTRIES', payload: null });
const newLeaderboard = await doCreateLeaderboard(
authenticatedUser,
gameId,
creationPayload
);
if (!newLeaderboard) return;
dispatch({
type: 'UPDATE_OR_CREATE_LEADERBOARD',
payload: newLeaderboard,
});
},
[gameId, authenticatedUser]
);
const fetchEntries = React.useCallback(
async (options?: {| uri?: ?string |}) => {
if (!currentLeaderboardId) return;
const uriToUse = options && options.uri ? options.uri : null;
dispatch({ type: 'SET_ENTRIES', payload: null });
const data = await listLeaderboardEntries(gameId, currentLeaderboardId, {
pageSize,
onlyBestEntry: displayOnlyBestEntry,
forceUri: uriToUse,
});
if (!data) return;
const fetchedEntries:
| LeaderboardEntry[]
| LeaderboardExtremePlayerScore[] = data.entries;
if (data.nextPageUri) {
dispatch({ type: 'SET_NEXT_PAGE_URI', payload: data.nextPageUri });
}
let entriesToDisplay: LeaderboardDisplayData[] = [];
if (displayOnlyBestEntry) {
entriesToDisplay = fetchedEntries.map(entry =>
// $FlowFixMe
extractExtremeScoreDisplayData(entry)
);
} else {
entriesToDisplay = fetchedEntries.map(entry =>
// $FlowFixMe
extractEntryDisplayData(entry)
);
}
dispatch({ type: 'SET_ENTRIES', payload: entriesToDisplay });
},
[currentLeaderboardId, displayOnlyBestEntry, gameId]
);
const selectLeaderboard = React.useCallback((leaderboardId: string) => {
dispatch({ type: 'SELECT_LEADERBOARD', payload: leaderboardId });
}, []);
const setDisplayOnlyBestEntry = React.useCallback((newValue: boolean) => {
dispatch({ type: 'CHANGE_DISPLAY_ONLY_BEST_ENTRY', payload: newValue });
}, []);
const updateLeaderboard = async (attributes: {|
name?: string,
sort?: LeaderboardSortOption,
playerUnicityDisplayChoice?: LeaderboardPlayerUnicityDisplayOption,
visibility?: LeaderboardVisibilityOption,
|}) => {
if (!currentLeaderboardId) return;
if (attributes.sort) dispatch({ type: 'PURGE_NAVIGATION' }); // When changing playerUnicityDisplayChoice, it will change the displayOnlyBestEntry state variable, which will purge navigation.
const updatedLeaderboard = await doUpdateLeaderboard(
authenticatedUser,
gameId,
currentLeaderboardId,
attributes
);
if (!updatedLeaderboard) return;
dispatch({
type: 'UPDATE_OR_CREATE_LEADERBOARD',
payload: updatedLeaderboard,
});
if (attributes.sort) await fetchEntries();
};
const resetLeaderboard = async () => {
if (!currentLeaderboardId) return;
dispatch({ type: 'PURGE_NAVIGATION' });
const updatedLeaderboard = await doResetLeaderboard(
authenticatedUser,
gameId,
currentLeaderboardId
);
if (!updatedLeaderboard) return;
dispatch({
type: 'UPDATE_OR_CREATE_LEADERBOARD',
payload: updatedLeaderboard,
});
fetchEntries();
};
const deleteLeaderboard = async () => {
if (!currentLeaderboardId || !leaderboardsByIds) return;
dispatch({ type: 'PURGE_NAVIGATION' });
await doDeleteLeaderboard(authenticatedUser, gameId, currentLeaderboardId);
dispatch({ type: 'REMOVE_LEADERBOARD', payload: currentLeaderboardId });
};
const deleteLeaderboardEntry = async (entryId: string) => {
if (!currentLeaderboardId) return;
await doDeleteLeaderboardEntry(
authenticatedUser,
gameId,
currentLeaderboardId,
entryId
);
fetchEntries({ uri: pageIndex > 0 ? mapPageIndexToUri[pageIndex] : null });
};
// --- Navigation ---
const navigateToNextPage = React.useCallback(
async () => {
const nextPageUri = mapPageIndexToUri[pageIndex + 1];
if (!nextPageUri) return;
dispatch({ type: 'SET_PAGE_INDEX', payload: pageIndex + 1 });
await fetchEntries({ uri: nextPageUri });
},
[fetchEntries, mapPageIndexToUri, pageIndex]
);
const navigateToPreviousPage = React.useCallback(
async () => {
if (pageIndex === 1) {
dispatch({ type: 'SET_PAGE_INDEX', payload: 0 });
await fetchEntries();
} else {
const previousPageUri = mapPageIndexToUri[pageIndex - 1];
if (!previousPageUri) return;
dispatch({ type: 'SET_PAGE_INDEX', payload: pageIndex - 1 });
await fetchEntries({ uri: previousPageUri });
}
},
[fetchEntries, mapPageIndexToUri, pageIndex]
);
const navigateToFirstPage = React.useCallback(
async () => {
dispatch({ type: 'SET_PAGE_INDEX', payload: 0 });
await fetchEntries();
},
[fetchEntries]
);
// --- Effects ---
React.useEffect(
() => {
if (!currentLeaderboardId) return;
dispatch({ type: 'PURGE_NAVIGATION' });
fetchEntries();
},
[currentLeaderboardId, displayOnlyBestEntry, fetchEntries]
);
return (
<LeaderboardContext.Provider
value={{
leaderboards: !!leaderboardsByIds
? // $FlowFixMe
Object.values(leaderboardsByIds)
: null,
currentLeaderboard,
displayOnlyBestEntry,
browsing: {
entries,
goToNextPage: !!mapPageIndexToUri[pageIndex + 1]
? navigateToNextPage
: null,
goToPreviousPage:
pageIndex === 1 || !!mapPageIndexToUri[pageIndex - 1]
? navigateToPreviousPage
: null,
goToFirstPage: pageIndex === 0 ? null : navigateToFirstPage,
},
setDisplayOnlyBestEntry,
createLeaderboard,
listLeaderboards,
selectLeaderboard,
updateLeaderboard,
resetLeaderboard,
deleteLeaderboard,
deleteLeaderboardEntry,
fetchLeaderboardEntries: fetchEntries,
}}
>
{children}
</LeaderboardContext.Provider>
);
};
export default LeaderboardProvider;

View File

@@ -133,6 +133,7 @@ import {
} from '../Utils/GDevelopServices/Badge';
import AuthenticatedUserContext from '../Profile/AuthenticatedUserContext';
import OnboardingDialog from './Onboarding/OnboardingDialog';
import LeaderboardProvider from '../Leaderboard/LeaderboardProvider';
const GD_STARTUP_TIMES = global.GD_STARTUP_TIMES || [];
@@ -2163,86 +2164,92 @@ const MainFrame = (props: Props) => {
onTabActived={(editorTab: EditorTab) => _onEditorTabActived(editorTab)}
onDropTab={onDropEditorTab}
/>
{getEditors(state.editorTabs).map((editorTab, id) => {
const isCurrentTab = getCurrentTabIndex(state.editorTabs) === id;
return (
<TabContentContainer key={editorTab.key} active={isCurrentTab}>
<CommandsContextScopedProvider active={isCurrentTab}>
<ErrorBoundary>
{editorTab.renderEditorContainer({
isActive: isCurrentTab,
extraEditorProps: editorTab.extraEditorProps,
project: currentProject,
ref: editorRef => (editorTab.editorRef = editorRef),
setToolbar: editorToolbar =>
setEditorToolbar(editorToolbar, isCurrentTab),
onChangeSubscription: () => openSubscriptionDialog(true),
projectItemName: editorTab.projectItemName,
setPreviewedLayout,
onOpenExternalEvents: openExternalEvents,
onOpenEvents: (sceneName: string) =>
openLayout(sceneName, {
openEventsEditor: true,
openSceneEditor: false,
}),
previewDebuggerServer,
hotReloadPreviewButtonProps,
onOpenLayout: name =>
openLayout(name, {
openEventsEditor: true,
openSceneEditor: false,
}),
resourceSources: props.resourceSources,
onChooseResource,
resourceExternalEditors,
onCreateEventsFunction,
openInstructionOrExpression,
unsavedChanges: unsavedChanges,
canOpen: !!props.storageProviders.filter(
({ hiddenInOpenDialog }) => !hiddenInOpenDialog
).length,
onOpen: () => chooseProject(),
onOpenRecentFile: openFromFileMetadataWithStorageProvider,
onCreateFromExampleShortHeader: onCreateFromExampleShortHeader,
onCreateBlank: onCreateBlank,
onOpenProjectAfterCreation: onOpenProjectAfterCreation,
onOpenProjectManager: () => openProjectManager(true),
onCloseProject: () => askToCloseProject(),
onOpenTutorials: () => onOpenTutorials(),
onOpenGamesShowcase: () => onOpenGamesShowcase(),
onOpenExamples: () => onOpenExamples(),
onOpenProfile: () => openProfileDialogWithTab('profile'),
onOpenHelpFinder: () => openHelpFinderDialog(true),
onOpenLanguageDialog: () => openLanguageDialog(true),
onLoadEventsFunctionsExtensions: () =>
eventsFunctionsExtensionsState.loadProjectEventsFunctionsExtensions(
currentProject
),
onDeleteResource: (
resource: gdResource,
cb: boolean => void
) => {
// TODO: Project wide refactoring of objects/events using the resource
cb(true);
},
onRenameResource: (
resource: gdResource,
newName: string,
cb: boolean => void
) => {
if (currentProject)
renameResourcesInProject(currentProject, {
[resource.getName()]: newName,
});
<LeaderboardProvider
gameId={
state.currentProject ? state.currentProject.getProjectUuid() : ''
}
>
{getEditors(state.editorTabs).map((editorTab, id) => {
const isCurrentTab = getCurrentTabIndex(state.editorTabs) === id;
return (
<TabContentContainer key={editorTab.key} active={isCurrentTab}>
<CommandsContextScopedProvider active={isCurrentTab}>
<ErrorBoundary>
{editorTab.renderEditorContainer({
isActive: isCurrentTab,
extraEditorProps: editorTab.extraEditorProps,
project: currentProject,
ref: editorRef => (editorTab.editorRef = editorRef),
setToolbar: editorToolbar =>
setEditorToolbar(editorToolbar, isCurrentTab),
onChangeSubscription: () => openSubscriptionDialog(true),
projectItemName: editorTab.projectItemName,
setPreviewedLayout,
onOpenExternalEvents: openExternalEvents,
onOpenEvents: (sceneName: string) =>
openLayout(sceneName, {
openEventsEditor: true,
openSceneEditor: false,
}),
previewDebuggerServer,
hotReloadPreviewButtonProps,
onOpenLayout: name =>
openLayout(name, {
openEventsEditor: true,
openSceneEditor: false,
}),
resourceSources: props.resourceSources,
onChooseResource,
resourceExternalEditors,
onCreateEventsFunction,
openInstructionOrExpression,
unsavedChanges: unsavedChanges,
canOpen: !!props.storageProviders.filter(
({ hiddenInOpenDialog }) => !hiddenInOpenDialog
).length,
onOpen: () => chooseProject(),
onOpenRecentFile: openFromFileMetadataWithStorageProvider,
onCreateFromExampleShortHeader: onCreateFromExampleShortHeader,
onCreateBlank: onCreateBlank,
onOpenProjectAfterCreation: onOpenProjectAfterCreation,
onOpenProjectManager: () => openProjectManager(true),
onCloseProject: () => askToCloseProject(),
onOpenTutorials: () => onOpenTutorials(),
onOpenGamesShowcase: () => onOpenGamesShowcase(),
onOpenExamples: () => onOpenExamples(),
onOpenProfile: () => openProfileDialogWithTab('profile'),
onOpenHelpFinder: () => openHelpFinderDialog(true),
onOpenLanguageDialog: () => openLanguageDialog(true),
onLoadEventsFunctionsExtensions: () =>
eventsFunctionsExtensionsState.loadProjectEventsFunctionsExtensions(
currentProject
),
onDeleteResource: (
resource: gdResource,
cb: boolean => void
) => {
// TODO: Project wide refactoring of objects/events using the resource
cb(true);
},
onRenameResource: (
resource: gdResource,
newName: string,
cb: boolean => void
) => {
if (currentProject)
renameResourcesInProject(currentProject, {
[resource.getName()]: newName,
});
cb(true);
},
})}
</ErrorBoundary>
</CommandsContextScopedProvider>
</TabContentContainer>
);
})}
cb(true);
},
})}
</ErrorBoundary>
</CommandsContextScopedProvider>
</TabContentContainer>
);
})}
</LeaderboardProvider>
<CommandPalette ref={commandPaletteRef} />
<LoaderModal show={showLoader} />
<HelpFinder

View File

@@ -80,6 +80,7 @@ const ProfileDialog = ({
cannotBeDismissed={false}
open={open}
noMargin
fullHeight
noTitleMargin
title={
<Tabs value={currentTab} onChange={_onChangeTab}>
@@ -113,7 +114,7 @@ const ProfileDialog = ({
{currentTab === 'games-dashboard' &&
(authenticatedUser.authenticated ? (
<Line>
<ColumnStackLayout expand>
<ColumnStackLayout expand noOverflowParent>
<GamesList project={currentProject} />
</ColumnStackLayout>
</Line>

View File

@@ -20,9 +20,9 @@ const styles = {
padding: 0,
overflowX: 'hidden',
},
flexRowBody: {
flexColumnBody: {
display: 'flex',
flexDirection: 'row',
flexDirection: 'column',
},
flexBody: {
display: 'flex',
@@ -70,7 +70,7 @@ type Props = {|
children: React.Node, // The content of the dialog
// Display:
flexRowBody?: boolean, //Check if necessary
flexColumnBody?: boolean,
flexBody?: boolean,
// Size
@@ -89,7 +89,7 @@ type DialogContentStyle = {
padding?: 0,
overflowX?: 'hidden',
display?: 'flex',
flexDirection?: 'row',
flexDirection?: 'row' | 'column',
};
/**
@@ -107,7 +107,7 @@ export default (props: Props) => {
noMargin,
title,
children,
flexRowBody,
flexColumnBody,
flexBody,
fullHeight,
noTitleMargin,
@@ -132,7 +132,7 @@ export default (props: Props) => {
const dialogContentStyle: DialogContentStyle = {
...(noMargin ? styles.noMarginBody : styles.defaultBody),
...((flexRowBody ? styles.flexRowBody : {}): DialogContentStyle),
...((flexColumnBody ? styles.flexColumnBody : {}): DialogContentStyle),
...((flexBody ? styles.flexBody : {}): DialogContentStyle),
};

View File

@@ -1,8 +1,6 @@
// @flow
import * as React from 'react';
import ThemeConsumer from './Theme/ThemeConsumer';
import BackgroundText from './BackgroundText';
// No i18n in this file
const styles = {
containerStyle: {
@@ -25,15 +23,9 @@ type Props = {|
* Also take a look at EmptyPlaceholder for a more visible placerholder.
*/
const EmptyMessage = (props: Props) => (
<ThemeConsumer>
{muiTheme => (
<div style={{ ...styles.containerStyle, ...props.style }}>
<BackgroundText style={props.messageStyle}>
{props.children}
</BackgroundText>
</div>
)}
</ThemeConsumer>
<div style={{ ...styles.containerStyle, ...props.style }}>
<BackgroundText style={props.messageStyle}>{props.children}</BackgroundText>
</div>
);
export default EmptyMessage;

View File

@@ -1,21 +1,23 @@
// @flow
import { Trans } from '@lingui/macro';
import * as React from 'react';
import { Trans } from '@lingui/macro';
import Container from '@material-ui/core/Container';
import { CircularProgress } from '@material-ui/core';
import Add from '@material-ui/icons/Add';
import { ColumnStackLayout } from './Layout';
import RaisedButton from '../UI/RaisedButton';
import { Column, LargeSpacer } from './Grid';
import HelpButton from '../UI/HelpButton';
import Text from '../UI/Text';
import Add from '@material-ui/icons/Add';
import RaisedButton from '../UI/RaisedButton';
import Container from '@material-ui/core/Container';
import { ColumnStackLayout } from './Layout';
type Props = {|
title: React.Node,
description: React.Node,
actionLabel: React.Node,
helpPagePath: string,
helpPagePath?: string,
actionButtonId?: string,
onAdd: () => void,
isLoading?: boolean,
|};
/**
@@ -43,7 +45,8 @@ export const EmptyPlaceholder = (props: Props) => (
label={props.actionLabel}
primary
onClick={props.onAdd}
icon={<Add />}
disabled={!!props.isLoading}
icon={props.isLoading ? <CircularProgress size={24} /> : <Add />}
id={props.actionButtonId}
/>
<HelpButton

View File

@@ -25,7 +25,7 @@ type IconProps =
// They should be self descriptive - refer to Material UI docs otherwise.
type Props = {|
...IconProps,
onClick?: (ev: any) => void,
onClick?: (ev: any) => void | Promise<void>,
onContextMenu?: () => void,
disabled?: boolean,
edge?: 'start' | 'end' | false,

View File

@@ -132,6 +132,7 @@ type ColumnStackLayoutProps = {|
expand?: boolean,
noMargin?: boolean,
children: React.Node,
noOverflowParent?: boolean,
|};
export const ColumnStackLayout = ({
@@ -140,6 +141,7 @@ export const ColumnStackLayout = ({
expand,
noMargin,
children,
noOverflowParent,
}: ColumnStackLayoutProps) => {
let isFirstChild = true;
return (
@@ -148,6 +150,7 @@ export const ColumnStackLayout = ({
justifyContent={justifyContent}
expand={expand}
noMargin={noMargin}
noOverflowParent={noOverflowParent}
>
{React.Children.map(children, (child, index) => {
if (!child) return null;

View File

@@ -4,27 +4,32 @@ import * as React from 'react';
import { Column, Line } from './Grid';
import RaisedButton from './RaisedButton';
import EmptyMessage from './EmptyMessage';
import GDevelopThemeContext from './Theme/ThemeContext';
type Props = {|
children: React.Node,
onRetry?: () => void | Promise<void>,
kind?: 'error' | 'valid' | 'warning',
|};
const PlaceholderError = ({ onRetry, children }: Props) => (
<Column expand alignItems="center">
<EmptyMessage>{children}</EmptyMessage>
<Line>
{onRetry && (
<RaisedButton
primary
label={<Trans>Retry</Trans>}
onClick={() => {
onRetry();
}}
/>
)}
</Line>
</Column>
);
const PlaceholderError = ({ onRetry, children, kind }: Props) => {
const theme = React.useContext(GDevelopThemeContext);
return (
<Column expand alignItems="center">
<EmptyMessage style={kind ? { color: theme.message[kind] } : undefined}>
{children}
</EmptyMessage>
<Line>
{onRetry && (
<RaisedButton
primary
label={<Trans>Retry</Trans>}
onClick={onRetry}
/>
)}
</Line>
</Column>
);
};
export default PlaceholderError;

View File

@@ -15,6 +15,14 @@ type Props = {|
icon?: React.Node,
onClick: ?() => void,
buildMenuTemplate: (i18n: I18nType) => Array<MenuItemTemplate>,
style?: {|
marginTop?: number,
marginBottom?: number,
marginLeft?: number,
marginRight?: number,
margin?: number,
flexShrink?: 0,
|},
|};
const shouldNeverBeCalled = () => {
@@ -46,6 +54,7 @@ const RaisedButtonWithSplitMenu = (props: Props) => {
color={primary ? 'primary' : 'default'}
disabled={disabled}
size="small"
style={props.style}
>
<Button
focusRipple={focusRipple}

View File

@@ -29,6 +29,7 @@ type Props = {|
flex?: 1,
width?: 'auto',
},
inputStyle?: {| fontSize: 14 |},
margin?: 'none' | 'dense',
floatingLabelText?: React.Node,
@@ -96,6 +97,7 @@ export default class SelectField extends React.Component<Props, {||}> {
}
: undefined
}
InputProps={{ style: props.inputStyle }}
InputLabelProps={{
shrink: true,
}}

View File

@@ -7,6 +7,8 @@ type Props = {|
children: ?React.Node,
/** Size of the text. `body` if not specified. */
size?: 'body' | 'body2' | 'title' | 'bold-title',
/** Color of the text */
color?: 'error' | 'primary' | 'secondary',
/** The text alignment. */
align?: 'inherit' | 'left' | 'center' | 'right' | 'justify',
/** Don't shrink the text if there is not enough place in a flex container. */
@@ -35,38 +37,52 @@ type Props = {|
|},
|};
type Interface = {||};
// A Text to be displayed in the app. Prefer using this
// than a `<p>`/`<span>` or `<div>` as this will help to maintain
// consistency of text in the whole app.
export default ({
children,
style,
size,
align,
noShrink,
noMargin,
displayInlineAsSpan,
}: Props) => (
<Typography
variant={
size === 'bold-title'
? 'h5'
: size === 'title'
? 'h6'
: size === 'body2'
? 'body2'
: 'body1'
}
component={displayInlineAsSpan ? 'span' : undefined}
style={{
...style,
display: displayInlineAsSpan ? 'inline-block' : undefined,
flexShrink: noShrink ? 0 : undefined,
marginTop: noMargin ? 0 : 6,
marginBottom: noMargin ? 0 : 6,
}}
align={align || 'inherit'}
>
{children}
</Typography>
const Text = React.forwardRef<Props, Interface>(
(
{
children,
style,
size,
color,
align,
noShrink,
noMargin,
displayInlineAsSpan,
...otherProps // Used by possible parent element (such as Tooltip) to pass down props.
},
ref
) => (
<Typography
variant={
size === 'bold-title'
? 'h5'
: size === 'title'
? 'h6'
: size === 'body2'
? 'body2'
: 'body1'
}
ref={ref}
color={color}
component={displayInlineAsSpan ? 'span' : undefined}
style={{
...style,
display: displayInlineAsSpan ? 'inline-block' : undefined,
flexShrink: noShrink ? 0 : undefined,
marginTop: noMargin ? 0 : 6,
marginBottom: noMargin ? 0 : 6,
}}
align={align || 'inherit'}
{...otherProps}
>
{children}
</Typography>
)
);
export default Text;

View File

@@ -71,6 +71,9 @@ type Props = {|
// Keyboard focus:
autoFocus?: boolean,
// String text field:
maxLength?: number,
// Number text field:
precision?: number,
max?: number,
@@ -248,6 +251,8 @@ export default class TextField extends React.Component<Props, {||}> {
onKeyUp: props.onKeyUp,
onKeyDown: props.onKeyDown,
onClick: props.onClick,
// String field props:
maxLength: props.maxLength,
// Number field props:
max: props.max,
min: props.min,

View File

@@ -66,3 +66,9 @@ export const GDevelopUserApi = {
? 'https://yrun9q6udj.execute-api.us-east-1.amazonaws.com/dev'
: 'https://api.gdevelop-app.com/user',
};
export const GDevelopPlayApi = {
baseUrl: isDev
? 'https://n9dsp0xfw6.execute-api.us-east-1.amazonaws.com/dev'
: 'https://api.gdevelop-app.com/play',
};

View File

@@ -71,6 +71,10 @@ export type AllShowcasedGames = {
filters: Filters,
};
export type GameApiError = {|
code: 'game-deletion/leaderboards-exist',
|};
export const allGameCategories = [
'action',
'adventure',

View File

@@ -0,0 +1,258 @@
// @flow
import axios from 'axios';
import { GDevelopPlayApi } from './ApiConfigs';
import { type AuthenticatedUser } from '../../Profile/AuthenticatedUserContext';
export type LeaderboardSortOption = 'ASC' | 'DESC';
export type LeaderboardVisibilityOption = 'HIDDEN' | 'PUBLIC';
export type LeaderboardPlayerUnicityDisplayOption =
| 'FREE'
| 'PREFER_UNIQUE'
| 'PREFER_NON_UNIQUE';
export type Leaderboard = {|
id: string,
gameId: string,
name: string,
sort: LeaderboardSortOption,
startDatetime: string,
deletedAt?: string,
playerUnicityDisplayChoice: LeaderboardPlayerUnicityDisplayOption,
visibility: LeaderboardVisibilityOption,
|};
export type LeaderboardEntry = {|
id: string,
leaderboardId: string,
playerId?: string,
playerName: string,
createdAt: string,
score: number,
deletedAt?: string,
outdatedAt?: string,
|};
export type LeaderboardDisplayData = {|
+id: string,
+playerName: string,
+createdAt: string,
+score: number,
|};
export type LeaderboardExtremePlayerScore = {|
leaderboardId: string,
playerId?: string,
playerName: string,
relatedEntryCreatedAt: string,
score: number,
relatedEntryId: string,
|};
export const extractEntryDisplayData = ({
playerName,
id,
score,
createdAt,
}: LeaderboardEntry): LeaderboardDisplayData => ({
id,
createdAt,
playerName,
score,
});
export const extractExtremeScoreDisplayData = ({
playerName,
relatedEntryId,
score,
relatedEntryCreatedAt,
}: LeaderboardExtremePlayerScore): LeaderboardDisplayData => ({
id: relatedEntryId,
createdAt: relatedEntryCreatedAt,
playerName,
score,
});
export const breakUuid = (uuid: string): string => `${uuid.split('-')[0]}-...`;
export const listGameLeaderboards = async (
authenticatedUser: AuthenticatedUser,
gameId: string
): Promise<?Array<Leaderboard>> => {
const { getAuthorizationHeader, firebaseUser } = authenticatedUser;
if (!firebaseUser) return;
const { uid: userId } = firebaseUser;
const authorizationHeader = await getAuthorizationHeader();
return axios
.get(`${GDevelopPlayApi.baseUrl}/game/${gameId}/leaderboard`, {
headers: { Authorization: authorizationHeader },
params: { userId },
})
.then(response =>
response.data.filter(leaderboard => !leaderboard.deletedAt)
);
};
export const extractNextPageUriFromLinkHeader = (
linkHeader: string
): ?string => {
const links = linkHeader.split(',').map(link => link.trim());
const mapRelationToUri = links.reduce((acc, link) => {
const relationRegexMatch = link.match(/;\srel="(\w*)"/);
const uriMatch = link.match(/^<(.*)>/);
if (acc && relationRegexMatch && uriMatch) {
acc[relationRegexMatch[1]] = uriMatch[1];
}
return acc;
}, {});
if (Object.keys(mapRelationToUri).includes('next')) {
return mapRelationToUri.next;
}
return null;
};
export const listLeaderboardEntries = async (
gameId: string,
leaderboardId: string,
options: {| pageSize: number, onlyBestEntry: boolean, forceUri: ?string |}
): Promise<{|
entries: LeaderboardEntry[] | LeaderboardExtremePlayerScore[],
nextPageUri: ?string,
|}> => {
const uri =
options.forceUri || `/game/${gameId}/leaderboard/${leaderboardId}/entry`;
// $FlowFixMe
const response = await axios.get(`${GDevelopPlayApi.baseUrl}${uri}`, {
params: options.forceUri
? null
: {
onlyBestEntry: options.onlyBestEntry,
perPage: options.pageSize,
},
});
const nextPageUri = response.headers.link
? extractNextPageUriFromLinkHeader(response.headers.link)
: null;
return {
entries: response.data,
nextPageUri,
};
};
export const createLeaderboard = async (
authenticatedUser: AuthenticatedUser,
gameId: string,
{ name, sort }: {| name: string, sort: LeaderboardSortOption |}
): Promise<?Leaderboard> => {
const { getAuthorizationHeader, firebaseUser } = authenticatedUser;
if (!firebaseUser) return;
const { uid: userId } = firebaseUser;
const authorizationHeader = await getAuthorizationHeader();
const response = await axios.post(
`${GDevelopPlayApi.baseUrl}/game/${gameId}/leaderboard`,
{
name,
sort,
},
{
headers: { Authorization: authorizationHeader },
params: { userId },
}
);
return response.data;
};
export const updateLeaderboard = async (
authenticatedUser: AuthenticatedUser,
gameId: string,
leaderboardId: string,
payload: {|
name?: string,
sort?: LeaderboardSortOption,
playerUnicityDisplayChoice?: LeaderboardPlayerUnicityDisplayOption,
visibility?: LeaderboardVisibilityOption,
|}
): Promise<?Leaderboard> => {
const { getAuthorizationHeader, firebaseUser } = authenticatedUser;
if (!firebaseUser) return;
const { uid: userId } = firebaseUser;
const authorizationHeader = await getAuthorizationHeader();
const response = await axios.patch(
`${GDevelopPlayApi.baseUrl}/game/${gameId}/leaderboard/${leaderboardId}`,
payload,
{
headers: { Authorization: authorizationHeader },
params: { userId },
}
);
return response.data;
};
export const resetLeaderboard = async (
authenticatedUser: AuthenticatedUser,
gameId: string,
leaderboardId: string
): Promise<?Leaderboard> => {
const { getAuthorizationHeader, firebaseUser } = authenticatedUser;
if (!firebaseUser) return;
const { uid: userId } = firebaseUser;
const authorizationHeader = await getAuthorizationHeader();
const response = await axios.put(
`${
GDevelopPlayApi.baseUrl
}/game/${gameId}/leaderboard/${leaderboardId}/reset`,
{},
{
headers: { Authorization: authorizationHeader },
params: { userId },
}
);
return response.data;
};
export const deleteLeaderboard = async (
authenticatedUser: AuthenticatedUser,
gameId: string,
leaderboardId: string
): Promise<?Leaderboard> => {
const { getAuthorizationHeader, firebaseUser } = authenticatedUser;
if (!firebaseUser) return;
const { uid: userId } = firebaseUser;
const authorizationHeader = await getAuthorizationHeader();
const response = await axios.delete(
`${GDevelopPlayApi.baseUrl}/game/${gameId}/leaderboard/${leaderboardId}`,
{
headers: { Authorization: authorizationHeader },
params: { userId },
}
);
return response.data;
};
export const deleteLeaderboardEntry = async (
authenticatedUser: AuthenticatedUser,
gameId: string,
leaderboardId: string,
entryId: string
): Promise<?Leaderboard> => {
const { getAuthorizationHeader, firebaseUser } = authenticatedUser;
if (!firebaseUser) return;
const { uid: userId } = firebaseUser;
const authorizationHeader = await getAuthorizationHeader();
const response = await axios.delete(
`${
GDevelopPlayApi.baseUrl
}/game/${gameId}/leaderboard/${leaderboardId}/entry/${entryId}`,
{
headers: { Authorization: authorizationHeader },
params: { userId },
}
);
return response.data;
};

View File

@@ -0,0 +1,39 @@
// @flow
import { extractNextPageUriFromLinkHeader } from './Play';
describe('Play service', () => {
describe('extractNextPageUriFromLinkHeader', () => {
test('it returns null if input is empty', () => {
expect(extractNextPageUriFromLinkHeader('')).toBeNull();
});
test('it returns null if input is not compliant', () => {
expect(
extractNextPageUriFromLinkHeader('https://www.liluo.io/ rel="next"')
).toBeNull();
});
test('it returns null if next link is not present', () => {
expect(
extractNextPageUriFromLinkHeader(
'<https://www.liluo.io/>; rel="prev", <https://www.liluo.io/>; rel="home"'
)
).toBeNull();
});
test('it returns URI if next link is present', () => {
expect(
extractNextPageUriFromLinkHeader(
'<https://www.liluo.io/>; rel="next", <https://www.liluo.io/>; rel="home"'
)
).toEqual('https://www.liluo.io/');
});
test('it returns URI with encoded query parameters', () => {
expect(
extractNextPageUriFromLinkHeader(
'<https://www.liluo.io/game/3723963b-4f27-4896-9d62-32b1b0adddd5/leaderboard/aa7a8a96-dcf5-405c-8844-f133bcf223c7/entries?after=%7B%22GSIRawSK%22%3A1.89%2C%22parentId%22%3A%22LID%23aa7a8a96-dcf5-405c-8844-f133bcf223c7%22%2C%22childId%22%3A%22LE%23497f82ec-3aba-4ff0-a001-07d7c128f890%22%7D&perPage=10&onlyBestEntry=false>; rel="next", <https://www.liluo.io/>; rel="home"'
)
).toEqual(
'https://www.liluo.io/game/3723963b-4f27-4896-9d62-32b1b0adddd5/leaderboard/aa7a8a96-dcf5-405c-8844-f133bcf223c7/entries?after=%7B%22GSIRawSK%22%3A1.89%2C%22parentId%22%3A%22LID%23aa7a8a96-dcf5-405c-8844-f133bcf223c7%22%2C%22childId%22%3A%22LE%23497f82ec-3aba-4ff0-a001-07d7c128f890%22%7D&perPage=10&onlyBestEntry=false'
);
});
});
});

View File

@@ -731,6 +731,7 @@ export const game1: Game = {
authorName: 'My company',
gameName: 'My Great Game',
createdAt: 1606065498,
publicWebBuildId: 'fake-publicwebbuild-id',
};
export const game2: Game = {

View File

@@ -0,0 +1,105 @@
// @flow
import * as React from 'react';
import { Trans } from '@lingui/macro';
import { action } from '@storybook/addon-actions';
import muiDecorator from '../ThemeDecorator';
import paperDecorator from '../PaperDecorator';
import { LeaderboardAdmin } from '../../GameDashboard/LeaderboardAdmin';
import LeaderboardContext from '../../Leaderboard/LeaderboardContext';
import {
type Leaderboard,
type LeaderboardSortOption,
} from '../../Utils/GDevelopServices/Play';
import { Column } from '../../UI/Grid';
import FixedHeightFlexContainer from '../FixedHeightFlexContainer';
export default {
title: 'LeaderboardAdmin',
component: LeaderboardAdmin,
decorators: [paperDecorator, muiDecorator],
};
const mockedLeaderboards = Array(5)
.fill(0)
.map((_, index) => ({
id: `489165zad49-a8ad6-4a984-dcz8da-hjqn983qh${index}`,
name: `Level ${index + 1}`,
sort: 'DESC',
gameId: 'fakeGameId',
startDatetime: '2021-11-18T10:19:50.417Z',
playerUnicityDisplayChoice: index % 2 === 0 ? 'PREFER_UNIQUE' : 'FREE',
visibility: index % 3 === 0 ? 'HIDDEN' : 'PUBLIC',
}));
const mockedEntries = Array(8)
.fill(0)
.map((_, index) => ({
id: `fze8f4ze9f489ze4f9zef4${index}`,
playerName: `player${index % 2}`,
score: Math.round(Math.random() * 20 + 150),
createdAt: new Date(
1647964688856 + Math.random() * -5000000000
).toISOString(),
}))
.sort((a, b) => a.score - b.score);
const leaderboardsByIds = mockedLeaderboards.reduce((acc, leaderboard) => {
acc[leaderboard.id] = leaderboard;
return acc;
}, {});
const MockLeaderboardProvider = ({ children }: {| children: React.Node |}) => {
const [
currentLeaderboard,
setCurrentLeaderboard,
] = React.useState<Leaderboard>(mockedLeaderboards[3]);
const [sort, setSort] = React.useState<LeaderboardSortOption>('ASC');
return (
<LeaderboardContext.Provider
value={{
leaderboards: mockedLeaderboards,
currentLeaderboard,
displayOnlyBestEntry:
currentLeaderboard.playerUnicityDisplayChoice === 'PREFER_UNIQUE',
browsing: {
entries: mockedEntries,
goToNextPage: null,
goToPreviousPage: null,
goToFirstPage: null,
},
setDisplayOnlyBestEntry: action('setDisplayOnlyBestEntry'),
createLeaderboard: () => {
throw new Error('createLeaderboard');
},
listLeaderboards: action('listLeaderboards'),
selectLeaderboard: leaderboardId => {
setCurrentLeaderboard(leaderboardsByIds[leaderboardId]);
},
updateLeaderboard: () => {
throw new Error('updateLeaderboard');
},
resetLeaderboard: () => {
throw new Error('resetLeaderboard');
},
deleteLeaderboard: () => {
throw new Error('deleteLeaderboard');
},
deleteLeaderboardEntry: () => {
throw new Error('deleteLeaderboardEntry');
},
fetchLeaderboardEntries: () => {
throw new Error('fetchLeaderboardEntries');
},
}}
>
{children}
</LeaderboardContext.Provider>
);
};
export const WithErrors = () => (
<MockLeaderboardProvider>
<FixedHeightFlexContainer height={500}>
<LeaderboardAdmin onLoading={() => action('onLoading')} />
</FixedHeightFlexContainer>
</MockLeaderboardProvider>
);

View File

@@ -0,0 +1,144 @@
// @flow
import * as React from 'react';
import { action } from '@storybook/addon-actions';
import muiDecorator from '../../ThemeDecorator';
import paperDecorator from '../../PaperDecorator';
import { testProject } from '../../GDevelopJsInitializerDecorator';
import LeaderboardIdField from '../../../EventsSheet/ParameterFields/LeaderboardIdField';
import ValueStateHolder from '../../ValueStateHolder';
import { Trans } from '@lingui/macro';
import LeaderboardContext from '../../../Leaderboard/LeaderboardContext';
import {
type Leaderboard,
type LeaderboardSortOption,
} from '../../../Utils/GDevelopServices/Play';
const gd: libGDevelop = global.gd;
export default {
title: 'ParameterFields/LeaderboardIdField',
component: LeaderboardIdField,
decorators: [paperDecorator, muiDecorator],
};
const mockedLeaderboards = Array(5)
.fill(0)
.map((_, index) => ({
id: `489165zad49-a8ad6-4a984-dcz8da-hjqn983qh${index}`,
name: `Level ${index + 1}`,
sort: 'DESC',
gameId: 'fakeGameId',
startDatetime: '2021-11-18T10:19:50.417Z',
playerUnicityDisplayChoice: index % 2 === 0 ? 'PREFER_UNIQUE' : 'FREE',
visibility: index % 3 === 0 ? 'HIDDEN' : 'PUBLIC',
}));
const mockedEntries = Array(8)
.fill(0)
.map((_, index) => ({
id: `fze8f4ze9f489ze4f9zef4${index}`,
playerName: `player${index % 2}`,
score: Math.round(Math.random() * 20 + 150),
createdAt: new Date(
1647964688856 + Math.random() * -5000000000
).toISOString(),
}))
.sort((a, b) => a.score - b.score);
const leaderboardsByIds = mockedLeaderboards.reduce((acc, leaderboard) => {
acc[leaderboard.id] = leaderboard;
return acc;
}, {});
const MockLeaderboardProvider = ({ children }: {| children: React.Node |}) => {
const [
currentLeaderboard,
setCurrentLeaderboard,
] = React.useState<Leaderboard>(mockedLeaderboards[3]);
const [sort, setSort] = React.useState<LeaderboardSortOption>('ASC');
return (
<LeaderboardContext.Provider
value={{
leaderboards: mockedLeaderboards,
currentLeaderboard,
displayOnlyBestEntry:
currentLeaderboard.playerUnicityDisplayChoice === 'PREFER_UNIQUE',
browsing: {
entries: mockedEntries,
goToNextPage: null,
goToPreviousPage: null,
goToFirstPage: null,
},
setDisplayOnlyBestEntry: action('setDisplayOnlyBestEntry'),
createLeaderboard: () => action('createLeaderboard'),
listLeaderboards: action('listLeaderboards'),
selectLeaderboard: leaderboardId => {
setCurrentLeaderboard(leaderboardsByIds[leaderboardId]);
},
updateLeaderboard: () => action('updateLeaderboard'),
resetLeaderboard: () => action('resetLeaderboard'),
deleteLeaderboard: () => action('deleteLeaderboard'),
deleteLeaderboardEntry: () => action('deleteLeaderboardEntry'),
fetchLeaderboardEntries: () => action('fetchLeaderboardEntries'),
}}
>
{children}
</LeaderboardContext.Provider>
);
};
export const Default = () => (
<MockLeaderboardProvider>
<ValueStateHolder
initialValue={''}
render={(value, onChange) => (
<LeaderboardIdField
project={testProject.project}
scope={{ layout: testProject.testLayout }}
globalObjectsContainer={testProject.project}
objectsContainer={testProject.testLayout}
value={value}
onChange={onChange}
/>
)}
/>
</MockLeaderboardProvider>
);
export const InitialValidLeaderboard = () => (
<MockLeaderboardProvider>
<ValueStateHolder
initialValue={'"489165zad49-a8ad6-4a984-dcz8da-hjqn983qh2"'}
render={(value, onChange) => (
<LeaderboardIdField
project={testProject.project}
scope={{ layout: testProject.testLayout }}
globalObjectsContainer={testProject.project}
objectsContainer={testProject.testLayout}
value={value}
onChange={onChange}
/>
)}
/>
</MockLeaderboardProvider>
);
export const InitialInvalidLeaderboard = () => (
<MockLeaderboardProvider>
<ValueStateHolder
initialValue={'5032ff25-6cd7-4adb-978d-8d2e532b16cf'}
render={(value, onChange) => (
<LeaderboardIdField
project={testProject.project}
scope={{ layout: testProject.testLayout }}
globalObjectsContainer={testProject.project}
objectsContainer={testProject.testLayout}
value={value}
onChange={onChange}
/>
)}
/>
</MockLeaderboardProvider>
);

View File

@@ -4894,18 +4894,14 @@ storiesOf('GameDashboard/GameCard', module)
<GameCard
game={game1}
isCurrentGame={false}
onOpenDetails={action('onOpenDetails')}
onOpenBuilds={action('onOpenBuilds')}
onOpenAnalytics={action('onOpenAnalytics')}
onOpenGameManager={action('onOpenGameManager')}
/>
))
.add('current game', () => (
<GameCard
game={game1}
isCurrentGame={true}
onOpenDetails={action('onOpenDetails')}
onOpenBuilds={action('onOpenBuilds')}
onOpenAnalytics={action('onOpenAnalytics')}
onOpenGameManager={action('onOpenGameManager')}
/>
));