mirror of
https://github.com/monkeytypegame/monkeytype.git
synced 2025-11-09 13:44:29 +08:00
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:
parent
0517e786e9
commit
66074c8314
13 changed files with 218 additions and 68 deletions
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
4
backend/src/types/types.d.ts
vendored
4
backend/src/types/types.d.ts
vendored
|
|
@ -77,8 +77,8 @@ declare namespace MonkeyTypes {
|
|||
// Data Model
|
||||
|
||||
interface UserProfileDetails {
|
||||
bio: string;
|
||||
keyboard: string;
|
||||
bio?: string;
|
||||
keyboard?: string;
|
||||
socialProfiles: {
|
||||
twitter?: string;
|
||||
github?: string;
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
})
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue