mirror of
https://github.com/4ian/GDevelop.git
synced 2025-10-15 10:19:04 +00:00
Compare commits
113 Commits
v5.0.134
...
add-sessio
Author | SHA1 | Date | |
---|---|---|---|
![]() |
d29071ed17 | ||
![]() |
acdfedf31c | ||
![]() |
3c34173c9c | ||
![]() |
7e2444d7db | ||
![]() |
444f05e33e | ||
![]() |
7ccce6b288 | ||
![]() |
a574bcd887 | ||
![]() |
f0e969f2df | ||
![]() |
bf3e51286d | ||
![]() |
323f033547 | ||
![]() |
0bd9bdd459 | ||
![]() |
17413ef14a | ||
![]() |
38f4c0a53a | ||
![]() |
767caf5847 | ||
![]() |
f195cb8dce | ||
![]() |
91014075a0 | ||
![]() |
a8fd0c0632 | ||
![]() |
42af5ce808 | ||
![]() |
0b2a16298a | ||
![]() |
4241695247 | ||
![]() |
0f74f197d9 | ||
![]() |
c97ccf2257 | ||
![]() |
3d678b4515 | ||
![]() |
ee36f090a0 | ||
![]() |
655956b596 | ||
![]() |
4832d08c1f | ||
![]() |
99d163208d | ||
![]() |
42e227d523 | ||
![]() |
4125510c02 | ||
![]() |
8fd96583a0 | ||
![]() |
46d033c0ca | ||
![]() |
3da8e7655a | ||
![]() |
75ab56f63f | ||
![]() |
d9c7a5bc4a | ||
![]() |
1eb8fce01a | ||
![]() |
06ceb99ad2 | ||
![]() |
2988dcd20e | ||
![]() |
930cc91b32 | ||
![]() |
cb991afb18 | ||
![]() |
d7c3de8cb4 | ||
![]() |
d4d1c1d0d6 | ||
![]() |
6718b17364 | ||
![]() |
e1c3d315dc | ||
![]() |
23119dd98e | ||
![]() |
ba80ff1198 | ||
![]() |
22faa8b2a7 | ||
![]() |
131d44b2ab | ||
![]() |
54744281a7 | ||
![]() |
7c72ed242f | ||
![]() |
3db6cbf047 | ||
![]() |
8a08b6757b | ||
![]() |
7181705d61 | ||
![]() |
4018d12ccd | ||
![]() |
c3adb5ee98 | ||
![]() |
a391185a47 | ||
![]() |
254c1b81ff | ||
![]() |
6ab722dd67 | ||
![]() |
fb4fc38c47 | ||
![]() |
c969b89b6e | ||
![]() |
8389371814 | ||
![]() |
594e3f0ea9 | ||
![]() |
35ebd460bf | ||
![]() |
96fa67beaf | ||
![]() |
83b81a47eb | ||
![]() |
99413c2910 | ||
![]() |
a8b313a16f | ||
![]() |
66df056164 | ||
![]() |
a6210c20f1 | ||
![]() |
12e2cadba8 | ||
![]() |
d8318653aa | ||
![]() |
782af76fcf | ||
![]() |
e492ab1a96 | ||
![]() |
374bb7e019 | ||
![]() |
8e34acd3cb | ||
![]() |
fa55366268 | ||
![]() |
07b6f5e488 | ||
![]() |
a7257214a1 | ||
![]() |
67579c1a80 | ||
![]() |
fa7d380273 | ||
![]() |
7413698411 | ||
![]() |
5928d019f5 | ||
![]() |
6861b0cd85 | ||
![]() |
a9c3347bbc | ||
![]() |
f4d8966c91 | ||
![]() |
bd6a58aa17 | ||
![]() |
8d0b83fb04 | ||
![]() |
daf63cfa39 | ||
![]() |
3973a4d48e | ||
![]() |
7b237f36fd | ||
![]() |
cedf62510f | ||
![]() |
985aa3c377 | ||
![]() |
58fb0f2d60 | ||
![]() |
6fcc0c6498 | ||
![]() |
5ea03b6d03 | ||
![]() |
c97e43520b | ||
![]() |
75ed73b563 | ||
![]() |
a932efce66 | ||
![]() |
919f450983 | ||
![]() |
22e79c1a5d | ||
![]() |
f929b17eeb | ||
![]() |
4286fe00f3 | ||
![]() |
a9e8b2aa00 | ||
![]() |
a8e77e9791 | ||
![]() |
037cf87670 | ||
![]() |
0f35245867 | ||
![]() |
b2a5b40571 | ||
![]() |
2ed4513ef7 | ||
![]() |
979f13ef3d | ||
![]() |
1fbb00d95a | ||
![]() |
14796132fe | ||
![]() |
efbe9bbc80 | ||
![]() |
18b5f0b6d0 | ||
![]() |
952498d982 |
@@ -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";
|
||||
|
@@ -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);
|
||||
});
|
||||
|
122
Extensions/Leaderboards/JsExtension.js
Normal file
122
Extensions/Leaderboards/JsExtension.js
Normal 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 [];
|
||||
},
|
||||
};
|
103
Extensions/Leaderboards/leaderboardstools.ts
Normal file
103
Extensions/Leaderboards/leaderboardstools.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
62
Extensions/Leaderboards/tests/leaderboardstools.spec.js
Normal file
62
Extensions/Leaderboards/tests/leaderboardstools.spec.js
Normal 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(
|
||||
'aPιâΥÉᚱnÀⅯeThatᎥsTooⅬonᏀToBeՏaѵÊĐThisPartAppears'
|
||||
)
|
||||
).to.be('aeThatsTooonToBeaThisPartAppea');
|
||||
});
|
||||
});
|
||||
});
|
@@ -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,
|
||||
|
@@ -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',
|
||||
|
7
newIDE/app/public/JsPlatform/Extensions/leaderboard.svg
Normal file
7
newIDE/app/public/JsPlatform/Extensions/leaderboard.svg
Normal 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 |
@@ -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. */
|
||||
}
|
||||
|
168
newIDE/app/src/EventsSheet/ParameterFields/LeaderboardIdField.js
Normal file
168
newIDE/app/src/EventsSheet/ParameterFields/LeaderboardIdField.js
Normal 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} />;
|
@@ -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`,
|
||||
|
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
@@ -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>
|
||||
|
@@ -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}
|
||||
|
@@ -107,6 +107,10 @@ export const GamesList = (props: Props) => {
|
||||
setOpenedGameInitialTab('analytics');
|
||||
setOpenedGame(game);
|
||||
}}
|
||||
onOpenLeaderboards={() => {
|
||||
setOpenedGameInitialTab('leaderboards');
|
||||
setOpenedGame(game);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{openedGame && (
|
||||
|
@@ -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;
|
804
newIDE/app/src/GameDashboard/LeaderboardAdmin/index.js
Normal file
804
newIDE/app/src/GameDashboard/LeaderboardAdmin/index.js
Normal 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;
|
63
newIDE/app/src/Leaderboard/LeaderboardContext.js
Normal file
63
newIDE/app/src/Leaderboard/LeaderboardContext.js
Normal 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;
|
40
newIDE/app/src/Leaderboard/LeaderboardDialog.js
Normal file
40
newIDE/app/src/Leaderboard/LeaderboardDialog.js
Normal 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;
|
416
newIDE/app/src/Leaderboard/LeaderboardProvider.js
Normal file
416
newIDE/app/src/Leaderboard/LeaderboardProvider.js
Normal 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;
|
@@ -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;
|
||||
|
@@ -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: {
|
||||
|
@@ -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
|
||||
|
@@ -80,6 +80,7 @@ const ProfileDialog = ({
|
||||
cannotBeDismissed={false}
|
||||
open={open}
|
||||
noMargin
|
||||
fullHeight
|
||||
noTitleMargin
|
||||
title={
|
||||
<Tabs value={currentTab} onChange={_onChangeTab}>
|
||||
|
@@ -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),
|
||||
};
|
||||
|
||||
|
@@ -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;
|
||||
|
@@ -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
|
||||
|
@@ -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,
|
||||
|
@@ -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;
|
||||
|
@@ -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}
|
||||
|
@@ -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,
|
||||
}}
|
||||
|
@@ -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;
|
||||
|
@@ -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,
|
||||
|
@@ -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',
|
||||
};
|
||||
|
@@ -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 = {|
|
||||
|
@@ -69,6 +69,10 @@ export type AllShowcasedGames = {
|
||||
filters: Filters,
|
||||
};
|
||||
|
||||
export type GameApiError = {|
|
||||
code: 'game-deletion/leaderboards-exist',
|
||||
|};
|
||||
|
||||
export const allGameCategories = [
|
||||
'action',
|
||||
'adventure',
|
||||
|
241
newIDE/app/src/Utils/GDevelopServices/Play.js
Normal file
241
newIDE/app/src/Utils/GDevelopServices/Play.js
Normal 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;
|
||||
};
|
39
newIDE/app/src/Utils/GDevelopServices/Play.spec.js
Normal file
39
newIDE/app/src/Utils/GDevelopServices/Play.spec.js
Normal 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'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
@@ -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>
|
||||
);
|
@@ -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>
|
||||
);
|
@@ -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')}
|
||||
/>
|
||||
));
|
||||
|
||||
|
@@ -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",
|
||||
|
Reference in New Issue
Block a user