impr(commandline): allow validation for text inputs (@fehmer) (#6692)

Co-authored-by: Miodec <jack@monkeytype.com>
This commit is contained in:
Christian Fehmer 2025-07-09 17:18:12 +02:00 committed by GitHub
parent a12999d1fe
commit b9cff9e504
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 256 additions and 14 deletions

View file

@ -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>

View file

@ -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;

View file

@ -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;
});

View file

@ -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;

View file

@ -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;
}