diff --git a/backend/__tests__/dal/user.spec.ts b/backend/__tests__/dal/user.spec.ts index 09ebc9fe0..964db51f1 100644 --- a/backend/__tests__/dal/user.spec.ts +++ b/backend/__tests__/dal/user.spec.ts @@ -75,10 +75,6 @@ const mockResultFilter = { }, }; -async function setBanned(uid: string, banned: boolean): Promise { - 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, + }, + ], + }); }); }); diff --git a/backend/__tests__/utils/misc.spec.ts b/backend/__tests__/utils/misc.spec.ts index bc48efe40..3ce38909f 100644 --- a/backend/__tests__/utils/misc.spec.ts +++ b/backend/__tests__/utils/misc.spec.ts @@ -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 = [ { diff --git a/backend/src/api/controllers/user.ts b/backend/src/api/controllers/user.ts index 6c83606b9..97de2d1be 100644 --- a/backend/src/api/controllers/user.ts +++ b/backend/src/api/controllers/user.ts @@ -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 { 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 = { 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"); } diff --git a/backend/src/api/routes/users.ts b/backend/src/api/routes/users.ts index f164724b6..37c1bd9a3 100644 --- a/backend/src/api/routes/users.ts +++ b/backend/src/api/routes/users.ts @@ -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), diff --git a/backend/src/dal/user.ts b/backend/src/dal/user.ts index 3470ef7b5..9417edc11 100644 --- a/backend/src/dal/user.ts +++ b/backend/src/dal/user.ts @@ -701,26 +701,24 @@ export async function recordAutoBanEvent( export async function updateProfile( uid: string, - updates: Partial + profileDetailUpdates: Partial, + inventory?: MonkeyTypes.UserInventory ): Promise { - 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"); - } } diff --git a/backend/src/types/types.d.ts b/backend/src/types/types.d.ts index 9006c1c71..b0ad96a87 100644 --- a/backend/src/types/types.d.ts +++ b/backend/src/types/types.d.ts @@ -77,8 +77,8 @@ declare namespace MonkeyTypes { // Data Model interface UserProfileDetails { - bio: string; - keyboard: string; + bio?: string; + keyboard?: string; socialProfiles: { twitter?: string; github?: string; diff --git a/backend/src/utils/misc.ts b/backend/src/utils/misc.ts index 7402383f9..9ac8bd123 100644 --- a/backend/src/utils/misc.ts +++ b/backend/src/utils/misc.ts @@ -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") diff --git a/frontend/src/styles/popups.scss b/frontend/src/styles/popups.scss index 0bb7804a3..60eaed801 100644 --- a/frontend/src/styles/popups.scss +++ b/frontend/src/styles/popups.scss @@ -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 { diff --git a/frontend/src/ts/ape/endpoints/users.ts b/frontend/src/ts/ape/endpoints/users.ts index ea32da16b..38249b759 100644 --- a/frontend/src/ts/ape/endpoints/users.ts +++ b/frontend/src/ts/ape/endpoints/users.ts @@ -178,10 +178,14 @@ export default class Users { } async updateProfile( - profileUpdates: Partial + profileUpdates: Partial, + selectedBadgeId?: number ): Promise { return await this.httpClient.patch(`${BASE_PATH}/profile`, { - payload: profileUpdates, + payload: { + ...profileUpdates, + selectedBadgeId, + }, }); } } diff --git a/frontend/src/ts/controllers/badge-controller.ts b/frontend/src/ts/controllers/badge-controller.ts index cd13909e2..2b49145ca 100644 --- a/frontend/src/ts/controllers/badge-controller.ts +++ b/frontend/src/ts/controllers/badge-controller.ts @@ -81,7 +81,11 @@ const badges: Record = { }, }; -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 `
${ + + const balloonText = (noText ? badge.name + ": " : "") + badge.description; + + let balloon = ""; + if (!noBalloon) { + balloon = `aria-label="${balloonText}" data-balloon-pos="right"`; + } + + return `
${ badge.icon ? `` : "" }${noText ? "" : `
${badge.name}
`}
`; } diff --git a/frontend/src/ts/elements/leaderboards.ts b/frontend/src/ts/elements/leaderboards.ts index d6abf0189..b0087708f 100644 --- a/frontend/src/ts/elements/leaderboards.ts +++ b/frontend/src/ts/elements/leaderboards.ts @@ -422,14 +422,12 @@ async function update(): Promise { 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 { language: currentLanguage, mode: "time", mode2, - ...dailyLeaderboardQuery, + ...getDailyLeaderboardQuery(), }); }) ); diff --git a/frontend/src/ts/popups/edit-profile-popup.ts b/frontend/src/ts/popups/edit-profile-popup.ts index f5f473131..3c26bdd4f 100644 --- a/frontend/src/ts/popups/edit-profile-popup.ts +++ b/frontend/src/ts/popups/edit-profile-popup.ts @@ -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 = `
${badgeOption}
`; + badgeIdsSelect.append(badgeWrapper); + }); + + badgeIdsSelect.prepend( + `
+
+ +
none
+
+
` + ); + + $(".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 { 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 { 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); diff --git a/frontend/static/html/popups.html b/frontend/static/html/popups.html index 7a86140a5..7c6077418 100644 --- a/frontend/static/html/popups.html +++ b/frontend/static/html/popups.html @@ -994,6 +994,10 @@
+
+ +
+
save