Compare commits

...

113 Commits

Author SHA1 Message Date
Fannie Yan
d29071ed17 Fix inline input flickering between name and uuid on click 2022-03-28 18:01:27 +02:00
Fannie Yan
acdfedf31c Review changes 2022-03-28 18:01:27 +02:00
Fannie Yan
3c34173c9c Send additional session information with leaderboard entry 2022-03-28 18:01:27 +02:00
Fannie Yan
7e2444d7db Specify style typing in RaisedButtonWithSplitMenu 2022-03-28 18:01:27 +02:00
Fannie Yan
444f05e33e Move fetch leaderboards API Call to field 2022-03-28 18:01:27 +02:00
Fannie Yan
7ccce6b288 Review Changes : Move Provider up and fetch in field 2022-03-28 18:01:27 +02:00
Fannie Yan
a574bcd887 Review changes : naming and typos 2022-03-28 18:01:27 +02:00
Fannie Yan
f0e969f2df Create and add Leaderboard field 2022-03-28 18:01:27 +02:00
Fannie Yan
bf3e51286d Review changes : better parameters typing for action and use svg 2022-03-28 18:01:27 +02:00
Fannie Yan
323f033547 Add action to save scores and condition and expression for last save status 2022-03-28 18:01:27 +02:00
AlexandreSi
0bd9bdd459 Fix new instruction editor dialog display 2022-03-28 18:01:27 +02:00
AlexandreSi
17413ef14a Set key in fragment to fix react error 2022-03-28 18:01:27 +02:00
AlexandreSi
38f4c0a53a Use fullwidth props - skip tests 2022-03-28 18:01:27 +02:00
AlexandreSi
767caf5847 Replace leaderboard autocomplete with native select 2022-03-28 18:01:27 +02:00
AlexandreSi
f195cb8dce Revert leaderboard player unicity display choice select to native one 2022-03-28 18:01:27 +02:00
AlexandreSi
91014075a0 Use Text component instead of Typography component for all text display in leaderboard admin 2022-03-28 18:01:27 +02:00
AlexandreSi
a8fd0c0632 Make it possible to use Text component as child of tooltip 2022-03-28 18:01:27 +02:00
AlexandreSi
42af5ce808 Add props to specify Text component color 2022-03-28 18:01:27 +02:00
AlexandreSi
0b2a16298a Fix Select onChange function 2022-03-28 18:01:27 +02:00
AlexandreSi
4241695247 Use the same font size for player unicity display choice menu items as for select input 2022-03-28 18:01:27 +02:00
AlexandreSi
0f74f197d9 Use select for playerUnicityDisplayChoice to match leaderboard select design 2022-03-28 18:01:27 +02:00
AlexandreSi
c97ccf2257 Display loader when creating new leaderboard 2022-03-28 18:01:27 +02:00
AlexandreS
3d678b4515 Add expression to format player name to send it to leaderboard 2022-03-28 15:54:50 +02:00
Fannie Yan
ee36f090a0 Remove unused imports 2022-03-25 15:47:00 +01:00
Fannie Yan
655956b596 Review changes : rename leaderboard props in EventsSheet 2022-03-25 15:09:07 +01:00
Fannie Yan
4832d08c1f Update story 2022-03-25 12:54:21 +01:00
Fannie Yan
99d163208d Use RaisedButtonWithSplitMenu 2022-03-25 12:54:07 +01:00
Fannie Yan
42e227d523 Review Changes : Move Provider up and fetch in field 2022-03-25 12:53:38 +01:00
Fannie Yan
4125510c02 Review changes : use breakUuid 2022-03-25 12:51:21 +01:00
Fannie Yan
8fd96583a0 Review changes : naming and typos 2022-03-25 12:49:41 +01:00
Fannie Yan
46d033c0ca Update storybook 2022-03-24 17:57:34 +01:00
Fannie Yan
3da8e7655a Create and add Leaderboard field 2022-03-24 17:33:34 +01:00
Fannie Yan
75ab56f63f Remove logs 2022-03-24 14:53:57 +01:00
Fannie Yan
d9c7a5bc4a Review changes : better parameters typing for action and use svg 2022-03-23 09:56:01 +01:00
Fannie Yan
1eb8fce01a Add action to save scores and condition and expression for last save status 2022-03-23 09:56:01 +01:00
Clément Pasteau
06ceb99ad2 Bump version to 5.0.130 2022-03-23 09:56:01 +01:00
Clément Pasteau
2988dcd20e Fix invalid game thumbnail URL 2022-03-23 09:56:01 +01:00
AlexandreSi
930cc91b32 Use px insteads of em to specify SelectField font size valid value 2022-03-23 09:42:30 +01:00
AlexandreSi
cb991afb18 Remove useless flexRowBody option for dialog content 2022-03-23 09:40:13 +01:00
AlexandreSi
d7c3de8cb4 Fix mock leaderboard uuid in story 2022-03-23 09:36:42 +01:00
AlexandreSi
d4d1c1d0d6 Rename ill-named method 2022-03-23 09:34:14 +01:00
AlexandreSi
6718b17364 Improve error messages 2022-03-23 09:32:33 +01:00
AlexandreSi
e1c3d315dc Improve props setting when function is not defined 2022-03-22 18:04:23 +01:00
AlexandreSi
23119dd98e Ternary > let null then if 2022-03-22 17:58:26 +01:00
AlexandreSi
ba80ff1198 Return state instead of throwing when receiving unknown action type 2022-03-22 17:57:12 +01:00
AlexandreSi
22faa8b2a7 Prevent defining a new function for the retry button of placeholder error component 2022-03-22 17:51:16 +01:00
AlexandreSi
131d44b2ab Disable dialog close button when loading instead of just displaying a loader 2022-03-22 17:46:55 +01:00
AlexandreSi
54744281a7 Use an englisher sentence 2022-03-22 17:45:15 +01:00
AlexandreSi
7c72ed242f Fix component methods naming 2022-03-22 17:39:04 +01:00
AlexandreSi
3db6cbf047 Use shouldValidate function to validate leaderboard name 2022-03-22 17:37:03 +01:00
AlexandreSi
8a08b6757b Fix naming for function 2022-03-22 17:35:52 +01:00
AlexandreSi
7181705d61 Improve error logging 2022-03-22 17:35:24 +01:00
AlexandreSi
4018d12ccd Improve flow type annotations 2022-03-22 17:33:38 +01:00
AlexandreSi
c3adb5ee98 Improve imports 2022-03-22 17:33:24 +01:00
AlexandreSi
a391185a47 Add story to test component error display 2022-03-22 17:23:43 +01:00
AlexandreSi
254c1b81ff Use same font size on leaderboard name text field 2022-03-22 15:10:09 +01:00
AlexandreSi
6ab722dd67 Tidy tooltips 2022-03-22 15:05:16 +01:00
AlexandreSi
fb4fc38c47 Fix leaderboard autocomplete equality test 2022-03-22 14:58:26 +01:00
AlexandreSi
c969b89b6e Fetch same page entries when removing an entry 2022-03-22 14:51:06 +01:00
AlexandreSi
8389371814 Add tooltips to pagination 2022-03-22 14:07:46 +01:00
AlexandreSi
594e3f0ea9 Add FlowFixMe on Flow known error 2022-03-22 12:39:40 +01:00
AlexandreSi
35ebd460bf Test extractNextPageUriFromLinkHeader 2022-03-22 12:38:35 +01:00
AlexandreSi
96fa67beaf Add a structure to leaderboard provider 2022-03-22 12:14:30 +01:00
AlexandreSi
83b81a47eb Purge navigation when doing important changes to leaderboard 2022-03-22 12:10:09 +01:00
AlexandreSi
99413c2910 Implement basic navigation for leaderboard entries 2022-03-22 11:56:13 +01:00
AlexandreSi
a8b313a16f Display leaderboard entries as small rows 2022-03-22 11:49:46 +01:00
AlexandreSi
66df056164 Add possibility to use uri returned by API to fetch next entries 2022-03-22 11:48:53 +01:00
AlexandreSi
a6210c20f1 Rename WrappedError with CenteredError 2022-03-21 18:15:23 +01:00
AlexandreSi
12e2cadba8 Use spacer for margin setting 2022-03-21 18:09:38 +01:00
AlexandreSi
d8318653aa Fix typo 2022-03-21 17:57:00 +01:00
AlexandreSi
782af76fcf Add context to console errors 2022-03-21 17:55:41 +01:00
AlexandreSi
e492ab1a96 Improve usability of autocomplete 2022-03-21 17:52:19 +01:00
AlexandreSi
374bb7e019 Use reducer to store leaderboard provider state 2022-03-21 17:32:55 +01:00
AlexandreSi
8e34acd3cb Add live url for play api 2022-03-21 10:26:48 +01:00
AlexandreSi
fa55366268 Remove warnings about having div inside p's because of alert message inside error placeholder 2022-03-17 17:45:08 +01:00
AlexandreSi
07b6f5e488 Add kind props to ErrorPlaceholder to change font color according to theme 2022-03-17 17:45:08 +01:00
AlexandreSi
a7257214a1 When changing the current leaderboard, define the display only best entry parameter of the fetch entries pai call according to leaderboard configuration 2022-03-17 17:45:08 +01:00
AlexandreSi
67579c1a80 Add field to configure leaderboard playerUnicityDisplayChoice parameter 2022-03-17 17:45:08 +01:00
AlexandreSi
fa7d380273 Allow to specify select field font size to match body2 variant size 2022-03-17 17:45:08 +01:00
AlexandreSi
7413698411 Display error for each api call in leaderboard admin 2022-03-17 17:45:08 +01:00
AlexandreSi
5928d019f5 Disable actions in leaderboard entries table 2022-03-17 17:45:08 +01:00
AlexandreSi
6861b0cd85 Wait for leaderboards to have been re-fetched before continuing after update 2022-03-17 17:45:08 +01:00
AlexandreSi
a9c3347bbc Add text ellipsis on player name when too long 2022-03-17 17:45:08 +01:00
AlexandreSi
f4d8966c91 Disable autoselect on leaderboard name autocomplete 2022-03-17 17:45:08 +01:00
AlexandreSi
bd6a58aa17 Display error message when trying to unregister a game that hase leaderboard live 2022-03-17 17:45:08 +01:00
AlexandreSi
8d0b83fb04 Focus text field after clicking on Edit icon 2022-03-17 17:44:17 +01:00
AlexandreSi
daf63cfa39 Display leaderboard entries table row header even if no entries 2022-03-17 17:44:17 +01:00
AlexandreSi
3973a4d48e Improve user experience with leaderboard's name text field 2022-03-17 17:44:17 +01:00
AlexandreSi
7b237f36fd Add maxLength props to text field input 2022-03-17 17:44:17 +01:00
AlexandreSi
cedf62510f Sort fetched leaderboards by their name 2022-03-17 17:44:17 +01:00
AlexandreSi
985aa3c377 Make profile dialog full height since its 2 tabs take full height 2022-03-17 17:44:17 +01:00
AlexandreSi
58fb0f2d60 Add leaderboard button in game card and fine tune display 2022-03-17 17:44:17 +01:00
AlexandreSi
6fcc0c6498 Add props to display dialog body in flex column 2022-03-17 17:44:17 +01:00
AlexandreSi
5ea03b6d03 Add Loading state to empty placeholder component 2022-03-17 17:44:17 +01:00
AlexandreSi
c97e43520b Disable actions when creating new leaderboard 2022-03-17 17:44:17 +01:00
AlexandreSi
75ed73b563 Set entries to null before fetching them 2022-03-17 17:44:17 +01:00
AlexandreSi
a932efce66 Improve leaderboard admin UI 2022-03-17 17:44:17 +01:00
AlexandreSi
919f450983 Add possibility to delete a leaderboard 2022-03-17 17:44:17 +01:00
AlexandreSi
22e79c1a5d Improve display of leaderboard information 2022-03-17 17:44:17 +01:00
AlexandreSi
f929b17eeb Set and fetch entries after and before main operations to display loaders 2022-03-17 17:44:17 +01:00
AlexandreSi
4286fe00f3 Add button to refresh entries 2022-03-17 17:44:17 +01:00
AlexandreSi
a9e8b2aa00 Add switch to display only player best entry 2022-03-17 17:44:17 +01:00
AlexandreSi
a8e77e9791 Add possibility to delete an entry from the list display 2022-03-17 17:44:17 +01:00
AlexandreSi
037cf87670 Display leaderboard entries in right part of dialog 2022-03-17 17:44:17 +01:00
AlexandreSi
0f35245867 Add possibility to reset leaderboard by clicking on the dedicated icon 2022-03-17 17:44:17 +01:00
AlexandreSi
b2a5b40571 Change leaderboard sort order on dedicated icon click 2022-03-17 17:44:17 +01:00
AlexandreSi
2ed4513ef7 Add options on autocomplete 2022-03-17 17:44:17 +01:00
AlexandreSi
979f13ef3d Add possibility to disable closing dames details dialog and use it in leaderboard admin 2022-03-17 17:44:17 +01:00
AlexandreSi
1fbb00d95a Create leaderboard admin component and use it in the game dashboard dialog 2022-03-17 17:44:17 +01:00
AlexandreSi
14796132fe Initialize leaderboard context 2022-03-17 17:43:33 +01:00
AlexandreSi
efbe9bbc80 Initialize play api client 2022-03-17 17:43:33 +01:00
AlexandreSi
18b5f0b6d0 Allow async onClick callbacks for IconButton component 2022-03-17 17:43:33 +01:00
AlexandreSi
952498d982 Make help page path optional in empty place holder as in help button 2022-03-17 17:43:33 +01:00
42 changed files with 2809 additions and 206 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,122 @@
// @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_.'
),
'',
'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.setPlayerScore');
extension
.addCondition(
'LastSentEntrySaveFailed',
_('Last entry failed to save'),
_('Check if the last sent entry failed to save in the leaderboard.'),
_('Last entry failed to be saved in the leaderboard'),
_(''),
'JsPlatform/Extensions/leaderboard.svg',
'JsPlatform/Extensions/leaderboard.svg'
)
.getCodeExtraInformation()
.setIncludeFile('Extensions/Leaderboards/leaderboardstools.js')
.setFunctionName('gdjs.evtTools.leaderboards.hasLastEntrySaveFailed');
extension
.addStrExpression(
'LastSentEntryStatusCode',
_('Status code of last sent entry'),
_('Get the status code of the last sent leaderboard entry.'),
_(''),
'JsPlatform/Extensions/leaderboard.svg'
)
.getCodeExtraInformation()
.setIncludeFile('Extensions/Leaderboards/leaderboardstools.js')
.setFunctionName('gdjs.evtTools.leaderboards.getLastSentEntryStatusCode');
extension
.addStrExpression(
'FormatPlayerName',
_('Format player name'),
_('Formats a name so that it can be submitted to a leaderboard.'),
_(''),
'JsPlatform/Extensions/leaderboard.svg'
)
.addParameter('string', _('Raw player name'), '', false)
.getCodeExtraInformation()
.setIncludeFile('Extensions/Leaderboards/leaderboardstools.js')
.setFunctionName('gdjs.evtTools.leaderboards.formatPlayerName');
return extension;
},
runExtensionSanityTests: function (
gd /*: libGDevelop */,
extension /*: gdPlatformExtension*/
) {
return [];
},
};

View File

@@ -0,0 +1,103 @@
namespace gdjs {
const logger = new gdjs.Logger('Leaderboards');
export namespace evtTools {
export namespace leaderboards {
let _scoreLastSentAt: number | null = null;
let _lastScore: number;
let _lastPlayerName: string;
let _lastErrorCode: number;
export const setPlayerScore = function (
runtimeScene: gdjs.RuntimeScene,
leaderboardId: string,
score: float,
playerName: string,
responseVar: gdjs.Variable,
errorVar: gdjs.Variable
) {
errorVar.setString('');
responseVar.setString('');
if (
(_lastPlayerName === playerName && _lastScore === score) ||
(!!_scoreLastSentAt && Date.now() - _scoreLastSentAt < 500)
) {
errorVar.setString('Wait before sending a new score.');
} else {
const baseUrl =
'https://n9dsp0xfw6.execute-api.us-east-1.amazonaws.com/dev';
// const baseUrl = 'https://api.gdevelop-app.com/play';
const game = runtimeScene.getGame();
const getLocation = () => {
if (typeof window !== 'undefined')
return (window as any).location.href;
else if (typeof cc !== 'undefined' && cc.sys) {
return cc.sys.platform;
} else return '';
};
fetch(
`${baseUrl}/game/${gdjs.projectData.properties.projectUuid}/leaderboard/${leaderboardId}/entry`,
{
body: JSON.stringify({
playerName: playerName,
score: score,
sessionId: game.getSessionId(),
clientPlayerId: game.getPlayerId(),
location: getLocation(),
}),
method: 'POST',
headers: { 'Content-Type': 'application/json' },
}
)
.then((response) => {
_scoreLastSentAt = Date.now();
if (!response.ok) {
errorVar.setString(response.status.toString());
_lastErrorCode = response.status;
return response.statusText;
} else {
_lastScore = score;
_lastPlayerName = playerName;
_lastErrorCode = response.status;
return response.text();
}
})
.then((data) => {
responseVar.setString(data);
})
.catch((error) => {
console.warn(
`Error while submitting a leaderboard score: ${error}`
);
errorVar.setString('REQUEST_NOT_SENT');
_lastErrorCode = 400;
});
}
};
export const hasLastEntrySaveFailed = function () {
return _lastErrorCode && _lastErrorCode >= 400;
};
export const getLastSentEntryStatusCode = function () {
return '' + _lastErrorCode;
};
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
.replace(/\s/, '_')
.replace(/[^\w|-]/g, '')
.slice(0, 30);
};
}
}
}

View File

@@ -0,0 +1,62 @@
// @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('Player Name')).to.be(
'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 non-accepted characters in a long name', () => {
expect(
gdjs.evtTools.leaderboards.formatPlayerName(
'aιâΥÉᚱÀeThatsTooonToBeՏaѵÊĐThisPartAppears'
)
).to.be('aeThatsTooonToBeaThisPartAppea');
});
});
});

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,168 @@
// @flow
import { Trans } from '@lingui/macro';
import { type ParameterInlineRendererProps } from './ParameterInlineRenderer.flow';
import React from 'react';
import { type ParameterFieldProps } 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 OpenInNew from '@material-ui/icons/OpenInNew';
import { t } from '@lingui/macro';
import LeaderboardDialog from '../../Leaderboard/LeaderboardDialog';
import GenericExpressionField from './GenericExpressionField';
import { breakUuid } from '../../Utils/GDevelopServices/Play';
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 function LeaderboardIdField(props: ParameterFieldProps) {
const leaderboards = useFetchLeaderboards();
const [isAdminOpen, setIsAdminOpen] = React.useState(false);
const isCurrentValueInLeaderboardList =
leaderboards &&
!!leaderboards.find(leaderboard => `"${leaderboard.id}"` === props.value);
const [isTextInput, setIsTextInput] = 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;
return (
<>
<TextFieldWithButtonLayout
renderTextField={() =>
!isTextInput ? (
<SelectField
value={props.value}
onChange={onChangeSelectValue}
margin={props.isInline ? 'none' : 'dense'}
fullWidth
floatingLabelText={fieldLabel}
hintText={t`Choose a leaderboard`}
>
{leaderboards && !!leaderboards.length
? leaderboards.map(leaderboard => (
<SelectOption
key={leaderboard.id}
value={`"${leaderboard.id}"`}
primaryText={`${leaderboard.name} ${
leaderboard.id
? `(${breakUuid(leaderboard.id.substring(0, 8))})`
: ''
}`}
/>
))
: null}
</SelectField>
) : (
<GenericExpressionField
expressionType="string"
{...props}
onChange={onChangeTextValue}
onExtractAdditionalErrors={(
currentExpression: string,
currentExpressionNode: gdExpressionNode
) => {
if (!leaderboards)
return `Unable to fetch leaderboards as you are offline.`;
}}
/>
)
}
renderButton={style => (
<>
<RaisedButtonWithSplitMenu
icon={<OpenInNew />}
style={style}
primary
onClick={() => setIsAdminOpen(true)}
buildMenuTemplate={i18n => [
{
label: isTextInput
? i18n._(t`Switch to expression`)
: i18n._(t`Switch to select`),
disabled: !leaderboards,
click: () => setIsTextInput(!isTextInput),
},
]}
/>
</>
)}
/>
{isAdminOpen && !!props.project && (
<LeaderboardDialog
onClose={() => setIsAdminOpen(false)}
open={isAdminOpen}
/>
)}
</>
);
}
export default React.forwardRef<ParameterFieldProps, {||}>(LeaderboardIdField);
const InlineLeaderboardIdField = ({
value,
InvalidParameterValue,
}: ParameterInlineRendererProps) => {
const leaderboards = useFetchLeaderboards();
if (!value) {
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 = {|
@@ -1555,11 +1559,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

@@ -9,6 +9,7 @@ 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 Cup from '@material-ui/icons/EmojiEvents';
import { ResponsiveLineStackLayout } from '../UI/Layout';
import Window from '../Utils/Window';
import FlatButton from '../UI/FlatButton';
@@ -31,6 +32,7 @@ type Props = {|
onOpenDetails: () => void,
onOpenBuilds: () => void,
onOpenAnalytics: () => void,
onOpenLeaderboards: () => void,
|};
export const GameCard = ({
@@ -39,6 +41,7 @@ export const GameCard = ({
onOpenDetails,
onOpenBuilds,
onOpenAnalytics,
onOpenLeaderboards,
}: Props) => {
const openGameUrl = () => {
const url = getGameUrl(game);
@@ -130,6 +133,11 @@ export const GameCard = ({
label={<Trans>Analytics</Trans>}
onClick={onOpenAnalytics}
/>
<FlatButton
icon={<Cup />}
label={<Trans>Leaderboards</Trans>}
onClick={onOpenLeaderboards}
/>
</ResponsiveLineStackLayout>
</CardActions>
</Column>

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';
@@ -48,6 +47,7 @@ import SportsEsportsIcon from '@material-ui/icons/SportsEsports';
import SmartphoneIcon from '@material-ui/icons/Smartphone';
import Crown from '../UI/CustomSvgIcons/Crown';
import { showErrorBox } from '../UI/Messages/MessageBox';
import LeaderboardAdmin from './LeaderboardAdmin';
const styles = {
tableRowStatColumn: {
@@ -55,7 +55,11 @@ const styles = {
},
};
export type GamesDetailsTab = 'details' | 'builds' | 'analytics';
export type GamesDetailsTab =
| 'details'
| 'builds'
| 'analytics'
| 'leaderboards';
type Props = {|
game: Game,
@@ -81,6 +85,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
);
@@ -208,15 +217,29 @@ export const GameDetailsDialog = ({
}
};
const unregisterGame = async () => {
const unregisterGame = async (i18n: I18nType) => {
if (!profile) return;
const { id } = profile;
setGameUnregisterErrorText(null);
setIsLoading(true);
try {
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 {
setIsLoading(false);
}
};
@@ -268,11 +291,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"
/>,
@@ -285,8 +313,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}>
@@ -437,7 +469,7 @@ export const GameDetailsDialog = ({
if (!answer) return;
unregisterGame();
unregisterGame(i18n);
}}
label={<Trans>Unregister this game</Trans>}
/>
@@ -466,6 +498,11 @@ export const GameDetailsDialog = ({
disabled={!isGameOpenedAsProject}
/>
</Line>
{gameUnregisterErrorText ? (
<PlaceholderError kind="error">
{gameUnregisterErrorText}
</PlaceholderError>
) : null}
</ColumnStackLayout>
)
) : null}

View File

@@ -107,6 +107,10 @@ export const GamesList = (props: Props) => {
setOpenedGameInitialTab('analytics');
setOpenedGame(game);
}}
onOpenLeaderboards={() => {
setOpenedGameInitialTab('leaderboards');
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,804 @@
// @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 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,
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';
type Props = {| onLoading: boolean => void |};
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 }: 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 {
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,
|}
) => {
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>
) : (
<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) {
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]
);
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 (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 />;
else {
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: '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

@@ -0,0 +1,63 @@
// @flow
import * as React from 'react';
import {
type Leaderboard,
type LeaderboardSortOption,
type LeaderboardDisplayData,
type LeaderboardPlayerUnicityDisplayOption,
} 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,
|}) => 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,40 @@
//@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,
|};
const LeaderboardDialog = ({ onClose, open }: 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} />
</Dialog>
);
};
export default LeaderboardDialog;

View File

@@ -0,0 +1,416 @@
// @flow
import * as React from 'react';
import LeaderboardContext from './LeaderboardContext';
import {
type Leaderboard,
type LeaderboardEntry,
type LeaderboardExtremePlayerScore,
type LeaderboardSortOption,
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(gameId);
fetchedLeaderboards.sort((a, b) => a.name.localeCompare(b.name));
dispatch({
type: 'SET_LEADERBOARDS',
payload: fetchedLeaderboards,
});
} finally {
isListingLeaderboards.current = false;
}
}
},
[gameId]
);
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,
|}) => {
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

@@ -5,6 +5,7 @@ import {
type RenderEditorContainerProps,
type RenderEditorContainerPropsWithRef,
} from './BaseEditor';
import LeaderboardProvider from '../../Leaderboard/LeaderboardProvider';
export class EventsEditorContainer extends React.Component<RenderEditorContainerProps> {
editor: ?EventsSheetInterface;

View File

@@ -13,6 +13,7 @@ import ExternalPropertiesDialog, {
} from './ExternalPropertiesDialog';
import Text from '../../UI/Text';
import { Line } from '../../UI/Grid';
import LeaderboardProvider from '../../Leaderboard/LeaderboardProvider';
const styles = {
container: {

View File

@@ -132,6 +132,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 || [];
@@ -2162,86 +2163,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}>

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

@@ -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

@@ -80,7 +80,7 @@ export const getWebBuildThumbnailUrl = (
}
// The exporter put asset files directly in the build folder.
// It's not factorized with the exporter because it's a temporary solution.
return `https:/games.gdevelop-app.com/game-${buildId}/${fileName}`;
return `https://games.gdevelop-app.com/game-${buildId}/${fileName}`;
};
type UploadOptions = {|

View File

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

View File

@@ -0,0 +1,241 @@
// @flow
import axios from 'axios';
import { GDevelopPlayApi } from './ApiConfigs';
import { type AuthenticatedUser } from '../../Profile/AuthenticatedUserContext';
export type Leaderboard = {|
id: string,
gameId: string,
name: string,
sort: 'DESC' | 'ASC',
startDatetime: string,
deletedAt?: string,
playerUnicityDisplayChoice: 'PREFER_UNIQUE' | 'PREFER_NON_UNIQUE' | 'FREE',
|};
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 LeaderboardSortOption = 'ASC' | 'DESC';
export type LeaderboardPlayerUnicityDisplayOption =
| 'FREE'
| 'PREFER_UNIQUE'
| 'PREFER_NON_UNIQUE';
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 = (gameId: string): Promise<Leaderboard[]> =>
axios
.get(`${GDevelopPlayApi.baseUrl}/game/${gameId}/leaderboards`)
.then(response => response.data);
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}/entries`;
// $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,
|}
): 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

@@ -0,0 +1,104 @@
// @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',
}));
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,143 @@
// @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',
}));
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

@@ -5165,6 +5165,7 @@ storiesOf('GameDashboard/GameCard', module)
onOpenDetails={action('onOpenDetails')}
onOpenBuilds={action('onOpenBuilds')}
onOpenAnalytics={action('onOpenAnalytics')}
onOpenLeaderboards={action('onOpenLeaderboards')}
/>
))
.add('current game', () => (
@@ -5174,6 +5175,7 @@ storiesOf('GameDashboard/GameCard', module)
onOpenDetails={action('onOpenDetails')}
onOpenBuilds={action('onOpenBuilds')}
onOpenAnalytics={action('onOpenAnalytics')}
onOpenLeaderboards={action('onOpenLeaderboards')}
/>
));

View File

@@ -2,7 +2,7 @@
"name": "gdevelop",
"productName": "GDevelop 5",
"description": "GDevelop 5 IDE - the open-source, cross-platform game engine designed for everyone",
"version": "5.0.129",
"version": "5.0.130",
"author": "Florian Rival",
"license": "MIT",
"homepage": "https://gdevelop-app.com",