Inventory badge selection (#3210) bruception miodec

* Fixed badges not showing on all time leaderboards

* updated types to support inventory and single badge id

* Add badge selection

* Fix bug

* Remove unnecessary prefill

* using new inventory system

* added no balloon option

* updated text color

* not showing balloon

* updated styling for showing badges selection

* Add badge selection

Co-authored-by: Bruception <bberr022@fiu.edu>
This commit is contained in:
Jack 2022-06-23 18:00:53 +02:00 committed by GitHub
parent 0517e786e9
commit 66074c8314
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 218 additions and 68 deletions

View file

@ -75,10 +75,6 @@ const mockResultFilter = {
},
};
async function setBanned(uid: string, banned: boolean): Promise<void> {
await UserDAL.getUsersCollection().updateOne({ uid }, { $set: { banned } });
}
describe("UserDal", () => {
it("should be able to insert users", async () => {
// given
@ -357,22 +353,42 @@ describe("UserDal", () => {
it("updateProfile should appropriately handle multiple profile updates", async () => {
await UserDAL.addUser("test name", "test email", "TestID");
await UserDAL.updateProfile("TestID", {
bio: "test bio",
});
await UserDAL.updateProfile(
"TestID",
{
bio: "test bio",
},
{
badges: [],
}
);
const user = await UserDAL.getUser("TestID", "test add result filters");
expect(user.profileDetails).toStrictEqual({
bio: "test bio",
});
await UserDAL.updateProfile("TestID", {
keyboard: "test keyboard",
socialProfiles: {
twitter: "test twitter",
},
expect(user.inventory).toStrictEqual({
badges: [],
});
await UserDAL.updateProfile(
"TestID",
{
keyboard: "test keyboard",
socialProfiles: {
twitter: "test twitter",
},
},
{
badges: [
{
id: 1,
selected: true,
},
],
}
);
const updatedUser = await UserDAL.getUser(
"TestID",
"test add result filters"
@ -384,15 +400,33 @@ describe("UserDal", () => {
twitter: "test twitter",
},
});
await UserDAL.updateProfile("TestID", {
bio: "test bio 2",
socialProfiles: {
github: "test github",
website: "test website",
},
expect(updatedUser.inventory).toStrictEqual({
badges: [
{
id: 1,
selected: true,
},
],
});
await UserDAL.updateProfile(
"TestID",
{
bio: "test bio 2",
socialProfiles: {
github: "test github",
website: "test website",
},
},
{
badges: [
{
id: 1,
},
],
}
);
const updatedUser2 = await UserDAL.getUser(
"TestID",
"test add result filters"
@ -406,16 +440,12 @@ describe("UserDal", () => {
website: "test website",
},
});
});
it("updateProfile should handle banned users properly", async () => {
await UserDAL.addUser("test name", "test email", "TestID");
await setBanned("TestID", true);
await expect(
UserDAL.updateProfile("TestID", {
bio: "test bio",
})
).rejects.toThrow("User is banned");
expect(updatedUser2.inventory).toStrictEqual({
badges: [
{
id: 1,
},
],
});
});
});

View file

@ -71,6 +71,35 @@ describe("Misc Utils", () => {
});
});
it("identity", () => {
const testCases = [
{
input: "",
expected: "string",
},
{
input: {},
expected: "object",
},
{
input: 0,
expected: "number",
},
{
input: null,
expected: "null",
},
{
input: undefined,
expected: "undefined",
},
];
_.each(testCases, ({ input, expected }) => {
expect(misc.identity(input)).toEqual(expected);
});
});
it("flattenObjectDeep", () => {
const testCases = [
{

View file

@ -427,12 +427,7 @@ export async function getProfile(
const profileData = {
...baseProfile,
inventory,
details: {
bio: "",
keyboard: "",
socialProfiles: {},
...profileDetails,
},
details: profileDetails,
};
return new MonkeyResponse("Profile retrieved", profileData);
@ -442,7 +437,21 @@ export async function updateProfile(
req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
const { uid } = req.ctx.decodedToken;
const { bio, keyboard, socialProfiles } = req.body;
const { bio, keyboard, socialProfiles, selectedBadgeId } = req.body;
const user = await UserDAL.getUser(uid, "update user profile");
if (user.banned) {
throw new MonkeyError(403, "Banned users cannot update their profile");
}
user.inventory?.badges.forEach((badge) => {
if (badge.id === selectedBadgeId) {
badge.selected = true;
} else {
delete badge.selected;
}
});
const profileDetailsUpdates: Partial<MonkeyTypes.UserProfileDetails> = {
bio: sanitizeString(bio),
@ -450,7 +459,7 @@ export async function updateProfile(
socialProfiles: _.mapValues(socialProfiles, sanitizeString),
};
await UserDAL.updateProfile(uid, profileDetailsUpdates);
await UserDAL.updateProfile(uid, profileDetailsUpdates, user.inventory);
return new MonkeyResponse("Profile updated");
}

View file

@ -434,6 +434,7 @@ router.patch(
body: {
bio: profileDetailsBase.max(150),
keyboard: profileDetailsBase.max(75),
selectedBadgeId: joi.number(),
socialProfiles: joi.object({
twitter: profileDetailsBase.max(20),
github: profileDetailsBase.max(39),

View file

@ -701,26 +701,24 @@ export async function recordAutoBanEvent(
export async function updateProfile(
uid: string,
updates: Partial<MonkeyTypes.UserProfileDetails>
profileDetailUpdates: Partial<MonkeyTypes.UserProfileDetails>,
inventory?: MonkeyTypes.UserInventory
): Promise<void> {
const profileUpdates = _.pickBy(
flattenObjectDeep(updates, "profileDetails"),
(value) => value !== undefined
const profileUpdates = _.omitBy(
flattenObjectDeep(profileDetailUpdates, "profileDetails"),
(value) =>
value === undefined || (_.isPlainObject(value) && _.isEmpty(value))
);
const updateResult = await getUsersCollection().updateOne(
await getUsersCollection().updateOne(
{
uid,
banned: {
$ne: true,
},
},
{
$set: profileUpdates,
$set: {
...profileUpdates,
inventory,
},
}
);
if (updateResult.matchedCount === 0) {
throw new MonkeyError(403, "User is banned");
}
}

View file

@ -77,8 +77,8 @@ declare namespace MonkeyTypes {
// Data Model
interface UserProfileDetails {
bio: string;
keyboard: string;
bio?: string;
keyboard?: string;
socialProfiles: {
twitter?: string;
github?: string;

View file

@ -28,7 +28,7 @@ export function kogasa(cov: number): number {
);
}
export function identity(value: string): string {
export function identity(value: any): string {
return Object.prototype.toString
.call(value)
.replace(/^\[object\s+([a-z]+)\]$/i, "$1")

View file

@ -1240,6 +1240,25 @@
min-height: 5rem;
max-height: 10rem;
}
.badge-selection-container {
display: flex;
flex-wrap: wrap;
}
.badge-selection-item {
margin-bottom: 0.5rem;
width: max-content;
opacity: 25%;
cursor: pointer;
margin-right: 0.5rem;
margin-bottom: 0.5rem;
}
.badge-selection-item.selected,
.badge-selection-item:hover {
opacity: 100%;
}
}
#customThemesWrapper {

View file

@ -178,10 +178,14 @@ export default class Users {
}
async updateProfile(
profileUpdates: Partial<MonkeyTypes.UserDetails>
profileUpdates: Partial<MonkeyTypes.UserDetails>,
selectedBadgeId?: number
): Promise<Ape.EndpointData> {
return await this.httpClient.patch(`${BASE_PATH}/profile`, {
payload: profileUpdates,
payload: {
...profileUpdates,
selectedBadgeId,
},
});
}
}

View file

@ -81,7 +81,11 @@ const badges: Record<number, MonkeyTypes.UserBadge> = {
},
};
export function getHTMLById(id: number, noText = false): string {
export function getHTMLById(
id: number,
noText = false,
noBalloon = false
): string {
const badge = badges[id];
if (!badge) {
return "";
@ -96,9 +100,15 @@ export function getHTMLById(id: number, noText = false): string {
if (badge.customStyle) {
style += badge.customStyle;
}
return `<div class="badge" aria-label="${
(noText ? badge.name + ": " : "") + badge.description
}" data-balloon-pos="right" style="${style}">${
const balloonText = (noText ? badge.name + ": " : "") + badge.description;
let balloon = "";
if (!noBalloon) {
balloon = `aria-label="${balloonText}" data-balloon-pos="right"`;
}
return `<div class="badge" ${balloon} style="${style}">${
badge.icon ? `<i class="fas ${badge.icon}"></i>` : ""
}${noText ? "" : `<div class="text">${badge.name}</div>`}</div>`;
}

View file

@ -422,14 +422,12 @@ async function update(): Promise<void> {
const timeModes = ["15", "60"];
const dailyLeaderboardQuery = getDailyLeaderboardQuery();
const leaderboardRequests = timeModes.map((mode2) => {
return Ape.leaderboards.get({
language: currentLanguage,
mode: "time",
mode2,
...dailyLeaderboardQuery,
...getDailyLeaderboardQuery(),
});
});
@ -440,7 +438,7 @@ async function update(): Promise<void> {
language: currentLanguage,
mode: "time",
mode2,
...dailyLeaderboardQuery,
...getDailyLeaderboardQuery(),
});
})
);

View file

@ -1,4 +1,5 @@
import Ape from "../ape";
import { getHTMLById } from "../controllers/badge-controller";
import * as DB from "../db";
import * as Loader from "../elements/loader";
import * as Notifications from "../elements/notifications";
@ -42,16 +43,53 @@ const keyboardInput = $("#editProfilePopup .keyboard");
const twitterInput = $("#editProfilePopup .twitter");
const githubInput = $("#editProfilePopup .github");
const websiteInput = $("#editProfilePopup .website");
const badgeIdsSelect = $("#editProfilePopup .badge-selection-container");
let currentSelectedBadgeId = -1;
function hydrateInputs(): void {
const snapshot = DB.getSnapshot();
const badges = snapshot.inventory?.badges ?? [];
const { bio, keyboard, socialProfiles } = snapshot.details ?? {};
currentSelectedBadgeId = -1;
bioInput.val(bio ?? "");
keyboardInput.val(keyboard ?? "");
twitterInput.val(socialProfiles?.twitter ?? "");
githubInput.val(socialProfiles?.github ?? "");
websiteInput.val(socialProfiles?.website ?? "");
badgeIdsSelect.html("");
badges?.forEach((badge: MonkeyTypes.Badge) => {
if (badge.selected) {
currentSelectedBadgeId = badge.id;
}
const badgeOption = getHTMLById(badge.id, false, true);
const badgeWrapper = `<div class="badge-selection-item ${
badge.selected ? "selected" : ""
}" selection-id=${badge.id}>${badgeOption}</div>`;
badgeIdsSelect.append(badgeWrapper);
});
badgeIdsSelect.prepend(
`<div class="badge-selection-item ${
currentSelectedBadgeId === -1 ? "selected" : ""
}" selection-id=${-1}>
<div class="badge">
<i class="fas fa-frown-open"></i>
<div class="text">none</div>
</div>
</div>`
);
$(".badge-selection-item").on("click", ({ currentTarget }) => {
const selectionId = $(currentTarget).attr("selection-id") as string;
currentSelectedBadgeId = parseInt(selectionId, 10);
badgeIdsSelect.find(".badge-selection-item").removeClass("selected");
$(currentTarget).addClass("selected");
});
}
function buildUpdatesFromInputs(): MonkeyTypes.UserDetails {
@ -61,7 +99,7 @@ function buildUpdatesFromInputs(): MonkeyTypes.UserDetails {
const github = (githubInput.val() ?? "") as string;
const website = (websiteInput.val() ?? "") as string;
const updates: MonkeyTypes.UserDetails = {
const profileUpdates: MonkeyTypes.UserDetails = {
bio,
keyboard,
socialProfiles: {
@ -71,14 +109,17 @@ function buildUpdatesFromInputs(): MonkeyTypes.UserDetails {
},
};
return updates;
return profileUpdates;
}
async function updateProfile(): Promise<void> {
const updates = buildUpdatesFromInputs();
Loader.show();
const response = await Ape.users.updateProfile(updates);
const response = await Ape.users.updateProfile(
updates,
currentSelectedBadgeId
);
Loader.hide();
if (response.status !== 200) {
@ -88,6 +129,13 @@ async function updateProfile(): Promise<void> {
const snapshot = DB.getSnapshot();
snapshot.details = updates;
snapshot.inventory?.badges.forEach((badge) => {
if (badge.id === currentSelectedBadgeId) {
badge.selected = true;
} else {
delete badge.selected;
}
});
Notifications.add("Profile updated", 1);

View file

@ -994,6 +994,10 @@
<label>website</label>
<input class="website" type="text" value="" />
</div>
<div>
<label>badge</label>
<div class="badge-selection-container"></div>
</div>
<div class="button edit-profile-submit">save</div>
</div>
</div>