Skip to content

Commit 31d1d51

Browse files
authored
feat: validate username on name update (@fehmer) (monkeytypegame#5961)
1 parent c7751d9 commit 31d1d51

File tree

4 files changed

+126
-27
lines changed

4 files changed

+126
-27
lines changed

frontend/src/ts/elements/input-indicator.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,13 @@ type InputIndicatorOption = {
66
};
77

88
export class InputIndicator {
9-
private inputElement: JQuery;
9+
private inputElement: JQuery | HTMLInputElement;
1010
private parentElement: JQuery;
1111
private options: Record<string, InputIndicatorOption>;
1212
private currentStatus: keyof typeof this.options | null;
1313

1414
constructor(
15-
inputElement: JQuery,
15+
inputElement: JQuery | HTMLInputElement,
1616
options: Record<string, InputIndicatorOption>
1717
) {
1818
this.inputElement = inputElement;
@@ -34,7 +34,7 @@ export class InputIndicator {
3434
? `data-balloon-length="large"`
3535
: ""
3636
}
37-
data-balloon-pos="up"
37+
data-balloon-pos="left"
3838
${option.message ?? "" ? `aria-label="${option.message}"` : ""}
3939
>
4040
<i class="fas fa-fw ${option.icon} ${

frontend/src/ts/modals/simple-modals.ts

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
} from "../utils/simple-modal";
3636
import { ShowOptions } from "../utils/animated-modal";
3737
import { GenerateDataRequest } from "@monkeytype/contracts/dev";
38+
import { UserNameSchema } from "@monkeytype/contracts/users";
3839

3940
type PopupKey =
4041
| "updateEmail"
@@ -449,6 +450,18 @@ list.updateName = new SimpleModal({
449450
placeholder: "new name",
450451
type: "text",
451452
initVal: "",
453+
validation: {
454+
schema: UserNameSchema,
455+
isValid: async (newName: string) => {
456+
const checkNameResponse = (
457+
await Ape.users.getNameAvailability({
458+
params: { name: newName },
459+
})
460+
).status;
461+
462+
return checkNameResponse === 200 ? true : "Name not available";
463+
},
464+
},
452465
},
453466
],
454467
buttonText: "update",
@@ -462,22 +475,6 @@ list.updateName = new SimpleModal({
462475
};
463476
}
464477

465-
const checkNameResponse = await Ape.users.getNameAvailability({
466-
params: { name: newName },
467-
});
468-
469-
if (checkNameResponse.status === 409) {
470-
return {
471-
status: 0,
472-
message: "Name not available",
473-
};
474-
} else if (checkNameResponse.status !== 200) {
475-
return {
476-
status: -1,
477-
message: "Failed to check name: " + checkNameResponse.body.message,
478-
};
479-
}
480-
481478
const updateNameResponse = await Ape.users.updateName({
482479
body: { name: newName },
483480
});
@@ -1275,6 +1272,9 @@ list.devGenerateData = new SimpleModal({
12751272
span.innerHTML = `if checked, user will be created with ${target.value}@example.com and password: password`;
12761273
return;
12771274
},
1275+
validation: {
1276+
schema: UserNameSchema,
1277+
},
12781278
},
12791279
{
12801280
type: "checkbox",

frontend/src/ts/utils/simple-modal.ts

Lines changed: 102 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { format as dateFormat } from "date-fns/format";
44
import * as Loader from "../elements/loader";
55
import * as Notifications from "../elements/notifications";
66
import * as ConnectionState from "../states/connection";
7+
import { InputIndicator } from "../elements/input-indicator";
8+
import { debounce } from "throttle-debounce";
79

810
type CommonInput<TType, TValue> = {
911
type: TType;
@@ -14,6 +16,25 @@ type CommonInput<TType, TValue> = {
1416
optional?: boolean;
1517
label?: string;
1618
oninput?: (event: Event) => void;
19+
/**
20+
* Validate the input value and indicate the validation result next to the input.
21+
* If the schema is defined it is always checked first.
22+
* Only if the schema validaton is passed or missing the `isValid` method is called.
23+
*/
24+
validation?: {
25+
/**
26+
* Zod schema to validate the input value against.
27+
* The indicator will show the error messages from the schema.
28+
*/
29+
schema?: Zod.Schema<TValue>;
30+
/**
31+
* Custom async validation method.
32+
* This is intended to be used for validations that cannot be handled with a Zod schema like server-side validations.
33+
* @param value current input value
34+
* @returns true if the `value` is valid, an errorMessage as string if it is invalid.
35+
*/
36+
isValid?: (value: string) => Promise<true | string>;
37+
};
1738
};
1839

1940
export type TextInput = CommonInput<"text", string>;
@@ -67,6 +88,9 @@ export type ExecReturn = {
6788
afterHide?: () => void;
6889
};
6990

91+
type CommonInputTypeWithIndicator = CommonInputType & {
92+
indicator?: InputIndicator;
93+
};
7094
type SimpleModalOptions = {
7195
id: string;
7296
title: string;
@@ -90,7 +114,7 @@ export class SimpleModal {
90114
modal: AnimatedModal;
91115
id: string;
92116
title: string;
93-
inputs: CommonInputType[];
117+
inputs: CommonInputTypeWithIndicator[];
94118
text?: string;
95119
textAllowHtml: boolean;
96120
buttonText: string;
@@ -286,10 +310,77 @@ export class SimpleModal {
286310
}
287311
inputs.append(buildTag({ tagname, classes, attributes }));
288312
}
313+
const element = document.querySelector(
314+
"#" + attributes["id"]
315+
) as HTMLInputElement;
289316
if (input.oninput !== undefined) {
290-
(
291-
document.querySelector("#" + attributes["id"]) as HTMLElement
292-
).oninput = input.oninput;
317+
element.oninput = input.oninput;
318+
}
319+
if (input.validation !== undefined) {
320+
const indicator = new InputIndicator(element, {
321+
valid: {
322+
icon: "fa-check",
323+
level: 1,
324+
},
325+
invalid: {
326+
icon: "fa-times",
327+
level: -1,
328+
},
329+
checking: {
330+
icon: "fa-circle-notch",
331+
spinIcon: true,
332+
level: 0,
333+
},
334+
});
335+
input.indicator = indicator;
336+
337+
const debouceIsValid = debounce(1000, async (value: string) => {
338+
const result = await input.validation?.isValid?.(value);
339+
340+
if (element.value !== value) {
341+
//value of the input has changed in the meantime. discard
342+
return;
343+
}
344+
345+
if (result === true) {
346+
indicator.show("valid");
347+
} else {
348+
indicator.show("invalid", result);
349+
}
350+
});
351+
352+
const validateInput = async (value: string): Promise<void> => {
353+
if (value === undefined || value === "") {
354+
indicator.hide();
355+
return;
356+
}
357+
if (input.validation?.schema !== undefined) {
358+
const schemaResult = input.validation.schema.safeParse(value);
359+
if (!schemaResult.success) {
360+
indicator.show(
361+
"invalid",
362+
schemaResult.error.errors.map((err) => err.message).join(", ")
363+
);
364+
return;
365+
}
366+
}
367+
368+
if (input.validation?.isValid !== undefined) {
369+
indicator.show("checking");
370+
void debouceIsValid(value);
371+
return;
372+
}
373+
374+
indicator.show("valid");
375+
};
376+
377+
element.oninput = async (event) => {
378+
const value = (event.target as HTMLInputElement).value;
379+
await validateInput(value);
380+
381+
//call original handler if defined
382+
input.oninput?.(event);
383+
};
293384
}
294385
});
295386

@@ -307,14 +398,14 @@ export class SimpleModal {
307398
}
308399
}
309400

310-
type CommonInputWithCurrentValue = CommonInputType & {
401+
type CommonInputWithCurrentValue = CommonInputTypeWithIndicator & {
311402
currentValue: string | undefined;
312403
};
313404

314405
const inputsWithCurrentValue: CommonInputWithCurrentValue[] = [];
315406
for (let i = 0; i < this.inputs.length; i++) {
316407
inputsWithCurrentValue.push({
317-
...(this.inputs[i] as CommonInputType),
408+
...(this.inputs[i] as CommonInputTypeWithIndicator),
318409
currentValue: vals[i],
319410
});
320411
}
@@ -328,6 +419,11 @@ export class SimpleModal {
328419
return;
329420
}
330421

422+
if (inputsWithCurrentValue.some((i) => i.indicator?.get() === "invalid")) {
423+
Notifications.add("Please solve all validation errors", 0);
424+
return;
425+
}
426+
331427
this.disableInputs();
332428
Loader.show();
333429
void this.execFn(this, ...vals).then((res) => {

packages/contracts/src/users.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,16 @@ export const GetUserResponseSchema = responseWithData(
3737
);
3838
export type GetUserResponse = z.infer<typeof GetUserResponseSchema>;
3939

40-
const UserNameSchema = doesNotContainProfanity(
40+
export const UserNameSchema = doesNotContainProfanity(
4141
"substring",
4242
z
4343
.string()
4444
.min(1)
4545
.max(16)
46-
.regex(/^[\da-zA-Z_-]+$/)
46+
.regex(
47+
/^[\da-zA-Z_-]+$/,
48+
"Can only contain lower/uppercase letters, underscare and minus."
49+
)
4750
);
4851

4952
export const CreateUserRequestSchema = z.object({

0 commit comments

Comments
 (0)