mirror of
https://github.com/monkeytypegame/monkeytype.git
synced 2025-09-06 22:56:49 +08:00
impr(commandline): allow validation for text inputs (@fehmer) (#6692)
Co-authored-by: Miodec <jack@monkeytype.com>
This commit is contained in:
parent
a12999d1fe
commit
b9cff9e504
5 changed files with 256 additions and 14 deletions
|
@ -1284,8 +1284,17 @@
|
|||
<div class="searchicon">
|
||||
<i class="fas fa-fw fa-search"></i>
|
||||
</div>
|
||||
<div class="checkingicon hidden">
|
||||
<i class="fas fa-fw fa-circle-notch fa-spin"></i>
|
||||
</div>
|
||||
<input type="text" class="input" placeholder="Type to search" />
|
||||
</div>
|
||||
<div class="warning hidden">
|
||||
<div class="icon">
|
||||
<i class="fas fa-fw fa-exclamation-triangle"></i>
|
||||
</div>
|
||||
<div class="text">This is some warning text.</div>
|
||||
</div>
|
||||
<div class="suggestions"></div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
|
|
@ -14,6 +14,16 @@
|
|||
.searchicon {
|
||||
color: var(--sub-color);
|
||||
margin: 1px 1rem 0 1rem;
|
||||
grid-column: 1/2;
|
||||
grid-row: 1/2;
|
||||
}
|
||||
|
||||
.checkingicon {
|
||||
color: var(--sub-color);
|
||||
margin: 1px 1rem 0 1rem;
|
||||
grid-column: 1/2;
|
||||
grid-row: 1/2;
|
||||
background-color: var(--bg-color);
|
||||
}
|
||||
|
||||
input {
|
||||
|
@ -30,6 +40,25 @@
|
|||
}
|
||||
}
|
||||
|
||||
&.hasError {
|
||||
animation: shake 0.1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.warning {
|
||||
background: var(--sub-alt-color);
|
||||
padding: 0.5em 0;
|
||||
font-size: 0.75em;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
.icon {
|
||||
color: var(--error-color);
|
||||
margin: 0 1.15rem;
|
||||
}
|
||||
.text {
|
||||
color: var(--error-color);
|
||||
}
|
||||
}
|
||||
|
||||
.suggestions {
|
||||
display: block;
|
||||
@extend .ffscroll;
|
||||
|
|
|
@ -10,7 +10,7 @@ import * as OutOfFocus from "../test/out-of-focus";
|
|||
import * as ActivePage from "../states/active-page";
|
||||
import { focusWords } from "../test/test-ui";
|
||||
import * as Loader from "../elements/loader";
|
||||
import { Command, CommandsSubgroup } from "./types";
|
||||
import { Command, CommandsSubgroup, CommandWithValidation } from "./types";
|
||||
import { areSortedArraysEqual } from "../utils/arrays";
|
||||
import { parseIntOptional } from "../utils/numbers";
|
||||
import { debounce } from "throttle-debounce";
|
||||
|
@ -21,6 +21,10 @@ type InputModeParams = {
|
|||
placeholder: string | null;
|
||||
value: string | null;
|
||||
icon: string | null;
|
||||
validation?: {
|
||||
status: "checking" | "success" | "failed";
|
||||
errorMessage?: string;
|
||||
};
|
||||
};
|
||||
|
||||
let activeIndex = 0;
|
||||
|
@ -175,6 +179,7 @@ function hide(clearModalChain = false): void {
|
|||
void modal.hide({
|
||||
clearModalChain,
|
||||
afterAnimation: async () => {
|
||||
hideWarning();
|
||||
addCommandlineBackground();
|
||||
if (ActivePage.get() === "test") {
|
||||
const isWordsFocused = $("#wordsInput").is(":focus");
|
||||
|
@ -202,6 +207,7 @@ async function goBackOrHide(): Promise<void> {
|
|||
await filterSubgroup();
|
||||
await showCommands();
|
||||
await updateActiveCommand();
|
||||
hideWarning();
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -212,6 +218,7 @@ async function goBackOrHide(): Promise<void> {
|
|||
await filterSubgroup();
|
||||
await showCommands();
|
||||
await updateActiveCommand();
|
||||
hideWarning();
|
||||
} else {
|
||||
hide();
|
||||
}
|
||||
|
@ -562,10 +569,36 @@ function handleInputSubmit(): void {
|
|||
if (inputModeParams.command === null) {
|
||||
throw new Error("Can't handle input submit - command is null");
|
||||
}
|
||||
inputModeParams.command.exec?.({
|
||||
commandlineModal: modal,
|
||||
input: inputValue,
|
||||
});
|
||||
|
||||
if (inputModeParams.validation?.status === "checking") {
|
||||
//validation ongoing, ignore the submit
|
||||
return;
|
||||
} else if (inputModeParams.validation?.status === "failed") {
|
||||
const cmdLine = $("#commandLine .modal");
|
||||
cmdLine
|
||||
.stop(true, true)
|
||||
.addClass("hasError")
|
||||
.animate({ undefined: 1 }, 500, () => {
|
||||
cmdLine.removeClass("hasError");
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if ("inputValueConvert" in inputModeParams.command) {
|
||||
inputModeParams.command.exec?.({
|
||||
commandlineModal: modal,
|
||||
|
||||
// @ts-expect-error this is fine
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
input: inputModeParams.command.inputValueConvert(inputValue),
|
||||
});
|
||||
} else {
|
||||
inputModeParams.command.exec?.({
|
||||
commandlineModal: modal,
|
||||
input: inputValue,
|
||||
});
|
||||
}
|
||||
|
||||
void AnalyticsController.log("usedCommandLine", {
|
||||
command: inputModeParams.command.id,
|
||||
});
|
||||
|
@ -695,6 +728,109 @@ async function decrementActiveIndex(): Promise<void> {
|
|||
await updateActiveCommand();
|
||||
}
|
||||
|
||||
function showWarning(message: string): void {
|
||||
const warningEl = modal.getModal().querySelector<HTMLElement>(".warning");
|
||||
const warningTextEl = modal
|
||||
.getModal()
|
||||
.querySelector<HTMLElement>(".warning .text");
|
||||
if (warningEl === null || warningTextEl === null) {
|
||||
throw new Error("Commandline warning element not found");
|
||||
}
|
||||
warningEl.classList.remove("hidden");
|
||||
warningTextEl.textContent = message;
|
||||
}
|
||||
|
||||
const showCheckingIcon = debounce(200, async () => {
|
||||
const checkingiconEl = modal
|
||||
.getModal()
|
||||
.querySelector<HTMLElement>(".checkingicon");
|
||||
if (checkingiconEl === null) {
|
||||
throw new Error("Commandline checking icon element not found");
|
||||
}
|
||||
checkingiconEl.classList.remove("hidden");
|
||||
});
|
||||
|
||||
function hideCheckingIcon(): void {
|
||||
showCheckingIcon.cancel({ upcomingOnly: true });
|
||||
|
||||
const checkingiconEl = modal
|
||||
.getModal()
|
||||
.querySelector<HTMLElement>(".checkingicon");
|
||||
if (checkingiconEl === null) {
|
||||
throw new Error("Commandline checking icon element not found");
|
||||
}
|
||||
checkingiconEl.classList.add("hidden");
|
||||
}
|
||||
|
||||
function hideWarning(): void {
|
||||
const warningEl = modal.getModal().querySelector<HTMLElement>(".warning");
|
||||
if (warningEl === null) {
|
||||
throw new Error("Commandline warning element not found");
|
||||
}
|
||||
warningEl.classList.add("hidden");
|
||||
}
|
||||
|
||||
function updateValidationResult(
|
||||
validation: NonNullable<InputModeParams["validation"]>
|
||||
): void {
|
||||
inputModeParams.validation = validation;
|
||||
if (validation.status === "checking") {
|
||||
showCheckingIcon();
|
||||
} else if (
|
||||
validation.status === "failed" &&
|
||||
validation.errorMessage !== undefined
|
||||
) {
|
||||
showWarning(validation.errorMessage);
|
||||
hideCheckingIcon();
|
||||
} else {
|
||||
hideWarning();
|
||||
hideCheckingIcon();
|
||||
}
|
||||
}
|
||||
|
||||
async function isValid(
|
||||
checkValue: unknown,
|
||||
originalValue: string,
|
||||
originalInput: HTMLInputElement,
|
||||
validation: CommandWithValidation<unknown>["validation"]
|
||||
): Promise<void> {
|
||||
updateValidationResult({ status: "checking" });
|
||||
|
||||
if (validation.schema !== undefined) {
|
||||
const schemaResult = validation.schema.safeParse(checkValue);
|
||||
|
||||
if (!schemaResult.success) {
|
||||
updateValidationResult({
|
||||
status: "failed",
|
||||
errorMessage: schemaResult.error.errors
|
||||
.map((err) => err.message)
|
||||
.join(", "),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (validation.isValid === undefined) {
|
||||
updateValidationResult({ status: "success" });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await validation.isValid(checkValue);
|
||||
if (originalInput.value !== originalValue) {
|
||||
//value has change in the meantime, discard result
|
||||
return;
|
||||
}
|
||||
|
||||
if (result === true) {
|
||||
updateValidationResult({ status: "success" });
|
||||
} else {
|
||||
updateValidationResult({
|
||||
status: "failed",
|
||||
errorMessage: result,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const modal = new AnimatedModal({
|
||||
dialogId: "commandLine",
|
||||
customEscapeHandler: (): void => {
|
||||
|
@ -785,6 +921,35 @@ const modal = new AnimatedModal({
|
|||
}
|
||||
});
|
||||
|
||||
input.addEventListener(
|
||||
"input",
|
||||
debounce(100, async (e) => {
|
||||
if (
|
||||
inputModeParams === null ||
|
||||
inputModeParams.command === null ||
|
||||
!("validation" in inputModeParams.command)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const originalInput = (e as InputEvent).target as HTMLInputElement;
|
||||
const currentValue = originalInput.value;
|
||||
let checkValue: unknown = currentValue;
|
||||
const command =
|
||||
inputModeParams.command as CommandWithValidation<unknown>;
|
||||
|
||||
if ("inputValueConvert" in command) {
|
||||
checkValue = command.inputValueConvert(currentValue);
|
||||
}
|
||||
await isValid(
|
||||
checkValue,
|
||||
currentValue,
|
||||
originalInput,
|
||||
command.validation
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
modalEl.addEventListener("mousemove", (_e) => {
|
||||
mouseMode = true;
|
||||
});
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
import { FontSizeSchema } from "@monkeytype/contracts/schemas/configs";
|
||||
import Config, * as UpdateConfig from "../../config";
|
||||
import { Command } from "../types";
|
||||
import { Command, withValidation } from "../types";
|
||||
|
||||
const commands: Command[] = [
|
||||
{
|
||||
withValidation({
|
||||
id: "changeFontSize",
|
||||
display: "Font size...",
|
||||
icon: "fa-font",
|
||||
|
@ -10,10 +11,15 @@ const commands: Command[] = [
|
|||
defaultValue: (): string => {
|
||||
return Config.fontSize.toString();
|
||||
},
|
||||
exec: ({ input }): void => {
|
||||
if (input === undefined || input === "") return;
|
||||
UpdateConfig.setFontSize(parseFloat(input));
|
||||
inputValueConvert: Number,
|
||||
validation: {
|
||||
schema: FontSizeSchema,
|
||||
},
|
||||
},
|
||||
exec: ({ input }): void => {
|
||||
if (input === undefined) return;
|
||||
UpdateConfig.setFontSize(input);
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
export default commands;
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
import { Config } from "@monkeytype/contracts/schemas/configs";
|
||||
import AnimatedModal from "../utils/animated-modal";
|
||||
import { z } from "zod";
|
||||
|
||||
// this file is needed becauase otherwise it would produce a circular dependency
|
||||
|
||||
export type CommandExecOptions = {
|
||||
input?: string;
|
||||
export type CommandExecOptions<T> = {
|
||||
input?: T;
|
||||
commandlineModal: AnimatedModal;
|
||||
};
|
||||
|
||||
|
@ -27,7 +28,7 @@ export type Command = {
|
|||
configKey?: keyof Config;
|
||||
configValue?: string | number | boolean | number[];
|
||||
configValueMode?: "include";
|
||||
exec?: (options: CommandExecOptions) => void;
|
||||
exec?: (options: CommandExecOptions<string>) => void;
|
||||
hover?: () => void;
|
||||
available?: () => boolean;
|
||||
active?: () => boolean;
|
||||
|
@ -35,9 +36,41 @@ export type Command = {
|
|||
customData?: Record<string, string | boolean>;
|
||||
};
|
||||
|
||||
export type CommandWithValidation<T> = (T extends string
|
||||
? Command
|
||||
: Omit<Command, "exec"> & {
|
||||
inputValueConvert: (val: string) => T;
|
||||
exec?: (options: CommandExecOptions<T>) => void;
|
||||
}) & {
|
||||
/**
|
||||
* Validate the input value and indicate the validation result
|
||||
* If the schema is defined it is always checked first.
|
||||
* Only if the schema validaton is passed or missing the `isValid` method is called.
|
||||
*/
|
||||
validation: {
|
||||
/**
|
||||
* Zod schema to validate the input value against.
|
||||
* The indicator will show the error messages from the schema.
|
||||
*/
|
||||
schema?: z.Schema<T>;
|
||||
/**
|
||||
* Custom async validation method.
|
||||
* This is intended to be used for validations that cannot be handled with a Zod schema like server-side validations.
|
||||
* @param value current input value
|
||||
* @param thisPopup the current modal
|
||||
* @returns true if the `value` is valid, an errorMessage as string if it is invalid.
|
||||
*/
|
||||
isValid?: (value: T) => Promise<true | string>;
|
||||
};
|
||||
};
|
||||
|
||||
export type CommandsSubgroup = {
|
||||
title: string;
|
||||
configKey?: keyof Config;
|
||||
list: Command[];
|
||||
beforeList?: () => void;
|
||||
};
|
||||
|
||||
export function withValidation<T>(command: CommandWithValidation<T>): Command {
|
||||
return command as unknown as Command;
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue