Get profile by name (#3585)

* handling query param
getting user by name if query param is present

* added function to get user by name

* split profile function to get user by uid or name

* added  function to check if get parameter exists in url

* updating the profile based on the url parameter

* using query param in url

* using get param

* renamed param name
adding search param to pathname in the deafult parameter value

* renamed param

* renamed param
added query validation

* extracted repeated query to a function

* missing await

* fixed typo
fixed validation

* using em for dynamic font sizes

* using em for dynamic font sizes

* added line height

* using em

* using em

* using page profile search instead

* updated the way data is passed into the page

* profile search page

* setting line height

* removed vertical align

* moved navigate to an event to avoid circular deps

* fixed route controller not being included

* removed unnecessary test

* showing 404 error

* improved query checking

* renamed query param

* fixed test

* note

* yeet

* cleaner type definition
This commit is contained in:
Jack 2022-09-22 17:45:57 +02:00 committed by GitHub
parent 3cc55d634f
commit 409f0a83e4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 376 additions and 64 deletions

View file

@ -1,5 +1,6 @@
import request from "supertest";
import app from "../../../src/app";
import * as Configuration from "../../../src/init/configuration";
const mockApp = request(app);
@ -17,8 +18,42 @@ describe("user controller test", () => {
name: "NewUser",
uid: "123456789",
email: "newuser@mail.com",
captcha: "captcha",
};
jest.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue({
//if stuff breaks this might be the reason
users: {
signUp: true,
discordIntegration: {
enabled: false,
},
autoBan: {
enabled: false,
maxCount: 5,
maxHours: 1,
},
profiles: {
enabled: false,
},
xp: {
enabled: false,
gainMultiplier: 0,
maxDailyBonus: 0,
minDailyBonus: 0,
streak: {
enabled: false,
maxStreakDays: 0,
maxStreakMultiplier: 0,
},
},
inbox: {
enabled: false,
maxMail: 0,
},
},
} as any);
await mockApp
.post("/users/signup")
.send(newUser)
@ -51,6 +86,8 @@ describe("user controller test", () => {
Accept: "application/json",
})
.expect(409);
jest.restoreAllMocks();
});
});
});

View file

@ -438,7 +438,14 @@ export async function removeFavoriteQuote(
export async function getProfile(
req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
const { uid } = req.params;
const { uidOrName } = req.params;
const { isUid } = req.query;
const user =
isUid !== undefined
? await UserDAL.getUser(uidOrName, "get user profile")
: await UserDAL.getUserByName(uidOrName, "get user profile");
const {
name,
@ -454,7 +461,7 @@ export async function getProfile(
discordAvatar,
xp,
streak,
} = await UserDAL.getUser(uid, "get user profile");
} = user;
const validTimePbs = _.pick(personalBests?.time, "15", "30", "60", "120");
const validWordsPbs = _.pick(personalBests?.words, "10", "25", "50", "100");

View file

@ -421,7 +421,7 @@ const requireProfilesEnabled = validateConfiguration({
});
router.get(
"/:uid/profile",
"/:uidOrName/profile",
requireProfilesEnabled,
authenticateRequest({
isPublic: true,
@ -430,7 +430,10 @@ router.get(
withApeRateLimiter(RateLimit.userProfileGet),
validateRequest({
params: {
uid: joi.string().required(),
uidOrName: joi.string().required(),
},
query: {
isUid: joi.string().allow(""),
},
}),
asyncHandler(UserController.getProfile)

View file

@ -136,16 +136,6 @@ export async function clearPb(uid: string): Promise<void> {
);
}
export async function isNameAvailable(name: string): Promise<boolean> {
const nameDocs = await getUsersCollection()
.find({ name })
.collation({ locale: "en", strength: 1 })
.limit(1)
.toArray();
return nameDocs.length === 0;
}
export async function updateQuoteRatings(
uid: string,
quoteRatings: MonkeyTypes.UserQuoteRatings
@ -175,6 +165,29 @@ export async function getUser(
return user;
}
async function findByName(name: string): Promise<MonkeyTypes.User | undefined> {
return (
await getUsersCollection()
.find({ name })
.collation({ locale: "en", strength: 1 })
.limit(1)
.toArray()
)[0];
}
export async function isNameAvailable(name: string): Promise<boolean> {
return (await findByName(name)) === undefined;
}
export async function getUserByName(
name: string,
stack: string
): Promise<MonkeyTypes.User> {
const user = await findByName(name);
if (!user) throw new MonkeyError(404, "User not found", stack);
return user;
}
export async function isDiscordIdAvailable(
discordId: string
): Promise<boolean> {

View file

@ -306,7 +306,7 @@ key {
color: var(--text-color);
cursor: pointer;
transition: background 0.125s, color 0.125s;
padding: 0.5rem;
padding: 0.5em;
border-radius: var(--roundness);
background: var(--sub-alt-color);
text-align: center;
@ -315,11 +315,11 @@ key {
align-content: center;
height: min-content;
height: -moz-min-content;
line-height: 1.25rem;
line-height: 1.25em;
appearance: none;
border: none;
font-family: inherit;
font-size: 1rem;
font-size: 1em;
&.active {
background: var(--main-color);
@ -454,15 +454,15 @@ key {
}
position: relative;
.statusIndicator {
width: 2.25rem;
height: 2.25rem;
width: 2.25em;
height: 2.25em;
position: absolute;
right: 0;
top: 0;
/* background: red; */
display: grid;
grid-template-columns: 2.25rem;
grid-template-rows: 2.25rem;
grid-template-columns: 2.25em;
grid-template-rows: 2.25em;
place-items: center center;
cursor: pointer;

View file

@ -5,10 +5,11 @@ textarea {
border-radius: var(--roundness);
background: var(--sub-alt-color);
color: var(--text-color);
padding: 0.5rem;
font-size: 1rem;
padding: 0.5em;
font-size: 1em;
font-family: var(--font);
caret-color: var(--main-color);
line-height: 1.25em;
}
textarea {

View file

@ -1,3 +1,26 @@
.pageProfileSearch {
align-content: center;
height: 100%;
display: grid;
.search {
justify-self: center;
max-width: 400px;
width: 100%;
display: grid;
grid-template-columns: 1fr auto;
gap: 1rem;
font-size: 1.25rem;
.title {
font-size: 1.25em;
grid-column: span 2;
color: var(--sub-color);
}
.button {
height: auto;
}
}
}
.pageProfile {
align-content: center;
height: 100%;
@ -14,6 +37,21 @@
transform: translate(-50%, -50%);
color: var(--main-color);
}
.error {
position: absolute;
z-index: 1;
font-size: 2rem;
background: var(--bg-color);
border-radius: var(--roundness);
padding: 2rem;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
color: var(--error-color);
.message {
display: inline;
}
}
}
.profile {

View file

@ -71,7 +71,6 @@
.fas {
margin-right: 0rem;
vertical-align: middle;
}
}
}

View file

@ -183,8 +183,12 @@ export default class Users {
});
}
async getProfile(uid: string): Promise<Ape.EndpointData> {
return await this.httpClient.get(`${BASE_PATH}/${uid}/profile`);
async getProfileByUid(uid: string): Promise<Ape.EndpointData> {
return await this.httpClient.get(`${BASE_PATH}/${uid}/profile?isUid`);
}
async getProfileByName(name: string): Promise<Ape.EndpointData> {
return await this.httpClient.get(`${BASE_PATH}/${name}/profile`);
}
async updateProfile(

View file

@ -1,4 +1,4 @@
import { navigate } from "../../controllers/route-controller";
import { navigate } from "../../observables/navigate-event";
import * as ChallengeController from "../../controllers/challenge-controller";
import * as TestLogic from "../../test/test-logic";
import { capitalizeFirstLetterOfEachWord } from "../../utils/misc";

View file

@ -1,4 +1,4 @@
import { navigate } from "../../controllers/route-controller";
import { navigate } from "../../observables/navigate-event";
import { toggleFullscreen } from "../../utils/misc";
const commands: MonkeyTypes.Command[] = [

View file

@ -46,7 +46,7 @@ import {
hideFavoriteQuoteLength,
showFavoriteQuoteLength,
} from "../test/test-config";
import { navigate } from "./route-controller";
import { navigate } from "../observables/navigate-event";
import { update as updateTagsCommands } from "../commandline/lists/tags";
export const gmailProvider = new GoogleAuthProvider();

View file

@ -28,7 +28,7 @@ import * as CompositionState from "../states/composition";
import * as TestInput from "../test/test-input";
import * as TestWords from "../test/test-words";
import * as Hangul from "hangul-js";
import { navigate } from "./route-controller";
import { navigate } from "../observables/navigate-event";
let dontInsertSpace = false;
let correctShiftUsed = true;

View file

@ -7,6 +7,7 @@ import * as PageAbout from "../pages/about";
import * as PageLogin from "../pages/login";
import * as PageLoading from "../pages/loading";
import * as PageProfile from "../pages/profile";
import * as PageProfileSearch from "../pages/profile-search";
import * as Page404 from "../pages/404";
import * as PageTransition from "../states/page-transition";
import type Page from "../pages/page";
@ -16,6 +17,7 @@ import * as Focus from "../test/focus";
interface ChangeOptions {
force?: boolean;
params?: { [key: string]: string };
data?: any;
}
export async function change(
@ -48,6 +50,7 @@ export async function change(
account: Account.page,
login: PageLogin.page,
profile: PageProfile.page,
profileSearch: PageProfileSearch.page,
404: Page404.page,
};
@ -72,7 +75,10 @@ export async function change(
Focus.set(false);
ActivePage.set(nextPage.name);
previousPage?.afterHide();
await nextPage?.beforeShow(options.params);
await nextPage?.beforeShow({
params: options.params,
data: options.data,
});
}
);
});

View file

@ -6,10 +6,11 @@ import * as PageAccount from "../pages/account";
import * as PageLogin from "../pages/login";
import * as Page404 from "../pages/404";
import * as PageProfile from "../pages/profile";
import * as PageProfileSearch from "../pages/profile-search";
import * as Leaderboards from "../elements/leaderboards";
import * as TestUI from "../test/test-ui";
import * as PageTransition from "../states/page-transition";
import { Auth } from "../firebase";
import * as NavigateEvent from "../observables/navigate-event";
//source: https://www.youtube.com/watch?v=OstALBk-jTc
// https://www.youtube.com/watch?v=OstALBk-jTc
@ -17,6 +18,7 @@ import { Auth } from "../firebase";
//this will be used in tribe
interface NavigateOptions {
empty?: boolean;
data?: any;
}
function pathToRegex(path: string): RegExp {
@ -92,27 +94,26 @@ const routes: Route[] = [
},
{
path: "/profile",
load: (): void => {
if (Auth.currentUser) {
navigate("/account");
} else {
navigate("/");
}
load: (_params): void => {
PageController.change(PageProfileSearch.page);
},
},
{
path: "/profile/:uid",
load: (params): void => {
path: "/profile/:uidOrName",
load: (params, options): void => {
PageController.change(PageProfile.page, {
force: true,
params,
params: {
uidOrName: params["uidOrName"],
},
data: options.data,
});
},
},
];
export function navigate(
url = window.location.pathname,
function nav(
url = window.location.pathname + window.location.search,
options = {} as NavigateOptions
): void {
if (
@ -158,15 +159,19 @@ document.addEventListener("DOMContentLoaded", () => {
const target = e?.target as HTMLLinkElement;
if (target.matches("[router-link]") && target?.href) {
e.preventDefault();
navigate(target.href);
nav(target.href);
}
});
});
$("#top .logo").on("click", () => {
navigate("/");
nav("/");
});
$(document).on("click", "#leaderboards a.entryName", () => {
Leaderboards.hide();
});
NavigateEvent.subscribe((url, options) => {
nav(url, options);
});

View file

@ -53,7 +53,7 @@ export class InputIndicator {
hide(): void {
this.parentElement.find(".statusIndicator div").addClass("hidden");
this.currentStatus = null;
$(this.inputElement).css("padding-right", "0.5rem");
$(this.inputElement).css("padding-right", "0.5em");
}
show(optionId: keyof typeof this.options, messageOverride?: string): void {
@ -74,7 +74,7 @@ export class InputIndicator {
indicator.attr("aria-label", messageOverride);
}
$(this.inputElement).css("padding-right", "2.1rem");
$(this.inputElement).css("padding-right", "2.1em");
}
get(): keyof typeof this.options | null {

View file

@ -322,9 +322,9 @@ async function fillTable(lb: LbKey, prepend?: number): Promise<void> {
}</td>
<td>
<div class="avatarNameBadge">${avatar}
<a href="${location.origin}/profile/${entry.uid}" class="entryName" uid=${
<a href="${location.origin}/profile/${
entry.uid
} router-link>${entry.name}</a>
}?isUid" class="entryName" uid=${entry.uid} router-link>${entry.name}</a>
${entry.badgeId ? getBadgeHTMLbyId(entry.badgeId) : ""}
</div>
</td>

View file

@ -23,6 +23,7 @@ import "./popups/edit-preset-popup";
import "./popups/simple-popups";
import "./controllers/input-controller";
import "./ready";
import "./controllers/route-controller";
import "./pages/about";
import "./popups/pb-tables-popup";
import "./elements/scroll-to-top";

View file

@ -0,0 +1,23 @@
type SubscribeFunction = (url?: string, options?: NavigateOptions) => void;
const subscribers: SubscribeFunction[] = [];
export function subscribe(fn: SubscribeFunction): void {
subscribers.push(fn);
}
interface NavigateOptions {
empty?: boolean;
data?: any;
}
export function navigate(url?: string, options?: NavigateOptions): void {
subscribers.forEach((fn) => {
try {
fn(url, options);
} catch (e) {
console.error("Navigate event subscriber threw an error");
console.error(e);
}
});
}

View file

@ -1110,7 +1110,7 @@ $(".pageAccount .content .group.aboveHistory .exportCSV").on("click", () => {
});
$(document).on("click", ".pageAccount .profile .details .copyLink", () => {
const url = `${location.origin}/profile/${Auth.currentUser?.uid}`;
const url = `${location.origin}/profile/${Auth.currentUser?.uid}?isUid`;
navigator.clipboard.writeText(url).then(
function () {

View file

@ -1,10 +1,15 @@
interface Options {
params?: Record<string, string>;
data?: any;
}
export default class Page {
public name: string;
public element: JQuery;
public pathname: string;
public beforeHide: () => Promise<void>;
public afterHide: () => Promise<void>;
public beforeShow: (params?: { [key: string]: string }) => Promise<void>;
public beforeShow: (options: Options) => Promise<void>;
public afterShow: () => Promise<void>;
constructor(
name: string,
@ -12,7 +17,7 @@ export default class Page {
pathname: string,
beforeHide: () => Promise<void>,
afterHide: () => Promise<void>,
beforeShow: (params?: { [key: string]: string }) => Promise<void>,
beforeShow: (options: Options) => Promise<void>,
afterShow: () => Promise<void>
) {
this.name = name;

View file

@ -0,0 +1,92 @@
import Page from "./page";
import { InputIndicator } from "../elements/input-indicator";
import { sleep } from "../utils/misc";
import Ape from "../ape";
import { navigate } from "../observables/navigate-event";
const searchIndicator = new InputIndicator(
$(".page.pageProfileSearch .search input"),
{
notFound: {
icon: "fa-user-slash",
level: -1,
},
error: {
icon: "fa-times",
level: -1,
},
checking: {
icon: "fa-circle-notch",
spinIcon: true,
level: 1,
},
}
);
function disableInputs(): void {
$(".page.pageProfileSearch .search .button").addClass("disabled");
$(".page.pageProfileSearch .search input").attr("disabled", "disabled");
}
function enableInputs(): void {
$(".page.pageProfileSearch .search .button").removeClass("disabled");
$(".page.pageProfileSearch .search input").removeAttr("disabled");
}
function areInputsDisabled(): boolean {
return (
$(".page.pageProfileSearch .search input").attr("disabled") !== undefined
);
}
async function lookupProfile(): Promise<void> {
searchIndicator.hide();
const name = $(".page.pageProfileSearch .search input").val() as string;
if (name === "") return;
searchIndicator.show("checking");
disableInputs();
await sleep(500);
const response = await Ape.users.getProfileByName(name);
enableInputs();
if (response.status === 404) {
searchIndicator.show("notFound", "User not found");
return;
} else if (response.status !== 200) {
searchIndicator.show("error", `Error: ${response.message}`);
return;
}
navigate(`/profile/${name}`, {
data: response.data,
});
}
$(".page.pageProfileSearch .search input").on("keyup", (e) => {
if (e.key === "Enter" && !areInputsDisabled()) lookupProfile();
});
$(".page.pageProfileSearch .search .button").on("click", () => {
if (areInputsDisabled()) return;
lookupProfile();
});
export const page = new Page(
"profileSearch",
$(".page.pageProfileSearch"),
"/profile",
async () => {
//
},
async () => {
//
},
async () => {
$(".page.pageProfileSearch input").val("");
searchIndicator.hide();
},
async () => {
$(".page.pageProfileSearch input").focus();
}
);

View file

@ -3,6 +3,7 @@ import Page from "./page";
import * as Profile from "../elements/profile";
import * as PbTables from "../account/pb-tables";
import * as Notifications from "../elements/notifications";
import { checkIfGetParameterExists } from "../utils/misc";
function reset(): void {
$(".page.pageProfile .preloader").removeClass("hidden");
@ -126,17 +127,45 @@ function reset(): void {
</div>`);
}
async function update(userId: string): Promise<void> {
const response = await Ape.users.getProfile(userId ?? "");
$(".page.pageProfile .preloader").addClass("hidden");
interface UpdateOptions {
uidOrName?: string;
data?: any;
}
if (response.status !== 200) {
// $(".page.pageProfile .failedToLoad").removeClass("hidden");
return Notifications.add("Failed to load profile: " + response.message, -1);
async function update(options: UpdateOptions): Promise<void> {
const getParamExists = checkIfGetParameterExists("isUid");
if (options.data) {
$(".page.pageProfile .preloader").addClass("hidden");
Profile.update("profile", options.data);
PbTables.update(options.data.personalBests, true);
} else if (options.uidOrName) {
const response =
getParamExists === true
? await Ape.users.getProfileByUid(options.uidOrName)
: await Ape.users.getProfileByName(options.uidOrName);
$(".page.pageProfile .preloader").addClass("hidden");
if (response.status === 404) {
const message =
getParamExists === true
? "User not found"
: `User ${options.uidOrName} not found`;
$(".page.pageProfile .preloader").addClass("hidden");
$(".page.pageProfile .error").removeClass("hidden");
$(".page.pageProfile .error .message").text(message);
} else if (response.status !== 200) {
// $(".page.pageProfile .failedToLoad").removeClass("hidden");
return Notifications.add(
"Failed to load profile: " + response.message,
-1
);
}
Profile.update("profile", response.data);
PbTables.update(response.data.personalBests, true);
} else {
Notifications.add("Missing update parameter!", -1);
}
Profile.update("profile", response.data);
PbTables.update(response.data.personalBests, true);
}
export const page = new Page(
@ -149,9 +178,22 @@ export const page = new Page(
async () => {
reset();
},
async (params) => {
reset();
update(params?.["uid"] ?? "");
async (options) => {
const uidOrName = options?.params?.["uidOrName"];
if (uidOrName) {
$(".page.pageProfile .preloader").removeClass("hidden");
$(".page.pageProfile .search").addClass("hidden");
$(".page.pageProfile .content").removeClass("hidden");
reset();
update({
uidOrName,
data: options?.["data"],
});
} else {
$(".page.pageProfile .preloader").addClass("hidden");
$(".page.pageProfile .search").removeClass("hidden");
$(".page.pageProfile .content").addClass("hidden");
}
},
async () => {
//

View file

@ -697,6 +697,28 @@ export function findGetParameter(
return result;
}
export function checkIfGetParameterExists(
parameterName: string,
getOverride?: string
): boolean {
let result = false;
let tmp = [];
let search = location.search;
if (getOverride) {
search = getOverride;
}
search
.substr(1)
.split("&")
.forEach(function (item) {
tmp = item.split("=");
if (tmp[0] === parameterName) result = true;
});
return result;
}
export function objectToQueryString<T extends string | number | boolean>(
obj: Record<string, T | T[]>
): string {

View file

@ -1,8 +1,22 @@
<div class="page pageProfileSearch hidden">
<div class="search">
<div class="title">Profile lookup</div>
<input class="username" type="text" placeholder="username" />
<div class="button">
<i class="fas fa-fw fa-chevron-right"></i>
</div>
</div>
</div>
<div class="page pageProfile hidden">
<div class="content">
<div class="preloader">
<i class="fas fa-fw fa-spin fa-circle-notch"></i>
</div>
<div class="error hidden">
<i class="fas fa-times"></i>
<div class="message"></div>
</div>
<div class="profile">
<div class="details none">
<div class="avatarAndName">