Skip to content

Commit b9cff9e

Browse files
fehmerMiodec
andauthored
impr(commandline): allow validation for text inputs (@fehmer) (monkeytypegame#6692)
Co-authored-by: Miodec <[email protected]>
1 parent a12999d commit b9cff9e

File tree

5 files changed

+255
-13
lines changed

5 files changed

+255
-13
lines changed

frontend/src/html/popups.html

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1284,8 +1284,17 @@
12841284
<div class="searchicon">
12851285
<i class="fas fa-fw fa-search"></i>
12861286
</div>
1287+
<div class="checkingicon hidden">
1288+
<i class="fas fa-fw fa-circle-notch fa-spin"></i>
1289+
</div>
12871290
<input type="text" class="input" placeholder="Type to search" />
12881291
</div>
1292+
<div class="warning hidden">
1293+
<div class="icon">
1294+
<i class="fas fa-fw fa-exclamation-triangle"></i>
1295+
</div>
1296+
<div class="text">This is some warning text.</div>
1297+
</div>
12891298
<div class="suggestions"></div>
12901299
</div>
12911300
</dialog>

frontend/src/styles/commandline.scss

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,16 @@
1414
.searchicon {
1515
color: var(--sub-color);
1616
margin: 1px 1rem 0 1rem;
17+
grid-column: 1/2;
18+
grid-row: 1/2;
19+
}
20+
21+
.checkingicon {
22+
color: var(--sub-color);
23+
margin: 1px 1rem 0 1rem;
24+
grid-column: 1/2;
25+
grid-row: 1/2;
26+
background-color: var(--bg-color);
1727
}
1828

1929
input {
@@ -30,6 +40,25 @@
3040
}
3141
}
3242

43+
&.hasError {
44+
animation: shake 0.1s ease-in-out infinite;
45+
}
46+
47+
.warning {
48+
background: var(--sub-alt-color);
49+
padding: 0.5em 0;
50+
font-size: 0.75em;
51+
display: grid;
52+
grid-template-columns: auto 1fr;
53+
.icon {
54+
color: var(--error-color);
55+
margin: 0 1.15rem;
56+
}
57+
.text {
58+
color: var(--error-color);
59+
}
60+
}
61+
3362
.suggestions {
3463
display: block;
3564
@extend .ffscroll;

frontend/src/ts/commandline/commandline.ts

Lines changed: 170 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import * as OutOfFocus from "../test/out-of-focus";
1010
import * as ActivePage from "../states/active-page";
1111
import { focusWords } from "../test/test-ui";
1212
import * as Loader from "../elements/loader";
13-
import { Command, CommandsSubgroup } from "./types";
13+
import { Command, CommandsSubgroup, CommandWithValidation } from "./types";
1414
import { areSortedArraysEqual } from "../utils/arrays";
1515
import { parseIntOptional } from "../utils/numbers";
1616
import { debounce } from "throttle-debounce";
@@ -21,6 +21,10 @@ type InputModeParams = {
2121
placeholder: string | null;
2222
value: string | null;
2323
icon: string | null;
24+
validation?: {
25+
status: "checking" | "success" | "failed";
26+
errorMessage?: string;
27+
};
2428
};
2529

2630
let activeIndex = 0;
@@ -175,6 +179,7 @@ function hide(clearModalChain = false): void {
175179
void modal.hide({
176180
clearModalChain,
177181
afterAnimation: async () => {
182+
hideWarning();
178183
addCommandlineBackground();
179184
if (ActivePage.get() === "test") {
180185
const isWordsFocused = $("#wordsInput").is(":focus");
@@ -202,6 +207,7 @@ async function goBackOrHide(): Promise<void> {
202207
await filterSubgroup();
203208
await showCommands();
204209
await updateActiveCommand();
210+
hideWarning();
205211
return;
206212
}
207213

@@ -212,6 +218,7 @@ async function goBackOrHide(): Promise<void> {
212218
await filterSubgroup();
213219
await showCommands();
214220
await updateActiveCommand();
221+
hideWarning();
215222
} else {
216223
hide();
217224
}
@@ -562,10 +569,36 @@ function handleInputSubmit(): void {
562569
if (inputModeParams.command === null) {
563570
throw new Error("Can't handle input submit - command is null");
564571
}
565-
inputModeParams.command.exec?.({
566-
commandlineModal: modal,
567-
input: inputValue,
568-
});
572+
573+
if (inputModeParams.validation?.status === "checking") {
574+
//validation ongoing, ignore the submit
575+
return;
576+
} else if (inputModeParams.validation?.status === "failed") {
577+
const cmdLine = $("#commandLine .modal");
578+
cmdLine
579+
.stop(true, true)
580+
.addClass("hasError")
581+
.animate({ undefined: 1 }, 500, () => {
582+
cmdLine.removeClass("hasError");
583+
});
584+
return;
585+
}
586+
587+
if ("inputValueConvert" in inputModeParams.command) {
588+
inputModeParams.command.exec?.({
589+
commandlineModal: modal,
590+
591+
// @ts-expect-error this is fine
592+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
593+
input: inputModeParams.command.inputValueConvert(inputValue),
594+
});
595+
} else {
596+
inputModeParams.command.exec?.({
597+
commandlineModal: modal,
598+
input: inputValue,
599+
});
600+
}
601+
569602
void AnalyticsController.log("usedCommandLine", {
570603
command: inputModeParams.command.id,
571604
});
@@ -695,6 +728,109 @@ async function decrementActiveIndex(): Promise<void> {
695728
await updateActiveCommand();
696729
}
697730

731+
function showWarning(message: string): void {
732+
const warningEl = modal.getModal().querySelector<HTMLElement>(".warning");
733+
const warningTextEl = modal
734+
.getModal()
735+
.querySelector<HTMLElement>(".warning .text");
736+
if (warningEl === null || warningTextEl === null) {
737+
throw new Error("Commandline warning element not found");
738+
}
739+
warningEl.classList.remove("hidden");
740+
warningTextEl.textContent = message;
741+
}
742+
743+
const showCheckingIcon = debounce(200, async () => {
744+
const checkingiconEl = modal
745+
.getModal()
746+
.querySelector<HTMLElement>(".checkingicon");
747+
if (checkingiconEl === null) {
748+
throw new Error("Commandline checking icon element not found");
749+
}
750+
checkingiconEl.classList.remove("hidden");
751+
});
752+
753+
function hideCheckingIcon(): void {
754+
showCheckingIcon.cancel({ upcomingOnly: true });
755+
756+
const checkingiconEl = modal
757+
.getModal()
758+
.querySelector<HTMLElement>(".checkingicon");
759+
if (checkingiconEl === null) {
760+
throw new Error("Commandline checking icon element not found");
761+
}
762+
checkingiconEl.classList.add("hidden");
763+
}
764+
765+
function hideWarning(): void {
766+
const warningEl = modal.getModal().querySelector<HTMLElement>(".warning");
767+
if (warningEl === null) {
768+
throw new Error("Commandline warning element not found");
769+
}
770+
warningEl.classList.add("hidden");
771+
}
772+
773+
function updateValidationResult(
774+
validation: NonNullable<InputModeParams["validation"]>
775+
): void {
776+
inputModeParams.validation = validation;
777+
if (validation.status === "checking") {
778+
showCheckingIcon();
779+
} else if (
780+
validation.status === "failed" &&
781+
validation.errorMessage !== undefined
782+
) {
783+
showWarning(validation.errorMessage);
784+
hideCheckingIcon();
785+
} else {
786+
hideWarning();
787+
hideCheckingIcon();
788+
}
789+
}
790+
791+
async function isValid(
792+
checkValue: unknown,
793+
originalValue: string,
794+
originalInput: HTMLInputElement,
795+
validation: CommandWithValidation<unknown>["validation"]
796+
): Promise<void> {
797+
updateValidationResult({ status: "checking" });
798+
799+
if (validation.schema !== undefined) {
800+
const schemaResult = validation.schema.safeParse(checkValue);
801+
802+
if (!schemaResult.success) {
803+
updateValidationResult({
804+
status: "failed",
805+
errorMessage: schemaResult.error.errors
806+
.map((err) => err.message)
807+
.join(", "),
808+
});
809+
return;
810+
}
811+
}
812+
813+
if (validation.isValid === undefined) {
814+
updateValidationResult({ status: "success" });
815+
return;
816+
}
817+
818+
const result = await validation.isValid(checkValue);
819+
if (originalInput.value !== originalValue) {
820+
//value has change in the meantime, discard result
821+
return;
822+
}
823+
824+
if (result === true) {
825+
updateValidationResult({ status: "success" });
826+
} else {
827+
updateValidationResult({
828+
status: "failed",
829+
errorMessage: result,
830+
});
831+
}
832+
}
833+
698834
const modal = new AnimatedModal({
699835
dialogId: "commandLine",
700836
customEscapeHandler: (): void => {
@@ -785,6 +921,35 @@ const modal = new AnimatedModal({
785921
}
786922
});
787923

924+
input.addEventListener(
925+
"input",
926+
debounce(100, async (e) => {
927+
if (
928+
inputModeParams === null ||
929+
inputModeParams.command === null ||
930+
!("validation" in inputModeParams.command)
931+
) {
932+
return;
933+
}
934+
935+
const originalInput = (e as InputEvent).target as HTMLInputElement;
936+
const currentValue = originalInput.value;
937+
let checkValue: unknown = currentValue;
938+
const command =
939+
inputModeParams.command as CommandWithValidation<unknown>;
940+
941+
if ("inputValueConvert" in command) {
942+
checkValue = command.inputValueConvert(currentValue);
943+
}
944+
await isValid(
945+
checkValue,
946+
currentValue,
947+
originalInput,
948+
command.validation
949+
);
950+
})
951+
);
952+
788953
modalEl.addEventListener("mousemove", (_e) => {
789954
mouseMode = true;
790955
});
Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,25 @@
1+
import { FontSizeSchema } from "@monkeytype/contracts/schemas/configs";
12
import Config, * as UpdateConfig from "../../config";
2-
import { Command } from "../types";
3+
import { Command, withValidation } from "../types";
34

45
const commands: Command[] = [
5-
{
6+
withValidation({
67
id: "changeFontSize",
78
display: "Font size...",
89
icon: "fa-font",
910
input: true,
1011
defaultValue: (): string => {
1112
return Config.fontSize.toString();
1213
},
14+
inputValueConvert: Number,
15+
validation: {
16+
schema: FontSizeSchema,
17+
},
1318
exec: ({ input }): void => {
14-
if (input === undefined || input === "") return;
15-
UpdateConfig.setFontSize(parseFloat(input));
19+
if (input === undefined) return;
20+
UpdateConfig.setFontSize(input);
1621
},
17-
},
22+
}),
1823
];
24+
1925
export default commands;

frontend/src/ts/commandline/types.ts

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { Config } from "@monkeytype/contracts/schemas/configs";
22
import AnimatedModal from "../utils/animated-modal";
3+
import { z } from "zod";
34

45
// this file is needed becauase otherwise it would produce a circular dependency
56

6-
export type CommandExecOptions = {
7-
input?: string;
7+
export type CommandExecOptions<T> = {
8+
input?: T;
89
commandlineModal: AnimatedModal;
910
};
1011

@@ -27,17 +28,49 @@ export type Command = {
2728
configKey?: keyof Config;
2829
configValue?: string | number | boolean | number[];
2930
configValueMode?: "include";
30-
exec?: (options: CommandExecOptions) => void;
31+
exec?: (options: CommandExecOptions<string>) => void;
3132
hover?: () => void;
3233
available?: () => boolean;
3334
active?: () => boolean;
3435
shouldFocusTestUI?: boolean;
3536
customData?: Record<string, string | boolean>;
3637
};
3738

39+
export type CommandWithValidation<T> = (T extends string
40+
? Command
41+
: Omit<Command, "exec"> & {
42+
inputValueConvert: (val: string) => T;
43+
exec?: (options: CommandExecOptions<T>) => void;
44+
}) & {
45+
/**
46+
* Validate the input value and indicate the validation result
47+
* If the schema is defined it is always checked first.
48+
* Only if the schema validaton is passed or missing the `isValid` method is called.
49+
*/
50+
validation: {
51+
/**
52+
* Zod schema to validate the input value against.
53+
* The indicator will show the error messages from the schema.
54+
*/
55+
schema?: z.Schema<T>;
56+
/**
57+
* Custom async validation method.
58+
* This is intended to be used for validations that cannot be handled with a Zod schema like server-side validations.
59+
* @param value current input value
60+
* @param thisPopup the current modal
61+
* @returns true if the `value` is valid, an errorMessage as string if it is invalid.
62+
*/
63+
isValid?: (value: T) => Promise<true | string>;
64+
};
65+
};
66+
3867
export type CommandsSubgroup = {
3968
title: string;
4069
configKey?: keyof Config;
4170
list: Command[];
4271
beforeList?: () => void;
4372
};
73+
74+
export function withValidation<T>(command: CommandWithValidation<T>): Command {
75+
return command as unknown as Command;
76+
}

0 commit comments

Comments
 (0)