impr: add validations to settings input (@fehmer) (#6751)

Co-authored-by: Miodec <jack@monkeytype.com>
This commit is contained in:
Christian Fehmer 2025-07-25 15:07:04 +02:00 committed by GitHub
parent da720ac130
commit e32155edbb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 668 additions and 608 deletions

View file

@ -256,9 +256,6 @@
step="1"
value=""
/>
<button class="save no-auto-handle">
<i class="fas fa-save fa-fw"></i>
</button>
</div>
<div class="buttons">
<button data-config-value="off">off</button>
@ -288,9 +285,6 @@
step="1"
value=""
/>
<button class="save no-auto-handle">
<i class="fas fa-save fa-fw"></i>
</button>
</div>
<div class="buttons">
<button data-config-value="off">off</button>
@ -322,9 +316,6 @@
step="1"
value=""
/>
<button class="save no-auto-handle">
<i class="fas fa-save fa-fw"></i>
</button>
</div>
<div class="buttons">
<button data-config-value="off">off</button>
@ -792,9 +783,6 @@
step="1"
value=""
/>
<button class="save no-auto-handle">
<i class="fas fa-save fa-fw"></i>
</button>
</div>
<div class="buttons">
<button data-config-value="off">off</button>
@ -1020,9 +1008,6 @@
min="10"
max="90"
/>
<button class="save no-auto-handle">
<i class="fas fa-save fa-fw"></i>
</button>
</div>
</div>
</div>
@ -1131,9 +1116,6 @@
class="input"
min="0"
/>
<button class="save no-auto-handle">
<i class="fas fa-save fa-fw"></i>
</button>
</div>
</div>
</div>
@ -1154,9 +1136,6 @@
class="input"
tabindex="0"
/>
<button class="save no-auto-handle">
<i class="fas fa-save fa-fw"></i>
</button>
</div>
</div>
</div>

View file

@ -90,7 +90,7 @@
.inputAndButton {
display: grid;
grid-template-columns: auto min-content;
gap: 0.5rem;
// gap: 0.5rem;
margin-bottom: 0.5rem;
span {
@ -104,6 +104,20 @@
margin-right: 0rem;
}
}
.hasError {
animation: shake 0.1s ease-in-out infinite;
}
.statusIndicator {
opacity: 0;
}
&:has(input:focus),
&:has([data-indicator-status="failed"]) {
.statusIndicator {
opacity: 1;
}
}
}
.rangeGroup {

View file

@ -14,6 +14,7 @@ import { Command, CommandsSubgroup, CommandWithValidation } from "./types";
import { areSortedArraysEqual } from "../utils/arrays";
import { parseIntOptional } from "../utils/numbers";
import { debounce } from "throttle-debounce";
import { createInputEventHandler } from "../elements/input-validation";
type CommandlineMode = "search" | "input";
type InputModeParams = {
@ -618,6 +619,18 @@ async function runActiveCommand(): Promise<void> {
value: command.defaultValue?.() ?? "",
icon: command.icon ?? "fa-chevron-right",
};
if ("validation" in command && !handlersCache.has(command.id)) {
const commandWithValidation = command as CommandWithValidation<unknown>;
const handler = createInputEventHandler(
updateValidationResult,
commandWithValidation.validation,
"inputValueConvert" in commandWithValidation
? commandWithValidation.inputValueConvert
: undefined
);
handlersCache.set(command.id, handler);
}
await updateInput(inputModeParams.value as string);
hideCommands();
} else if (command.subgroup) {
@ -788,48 +801,10 @@ function updateValidationResult(
}
}
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,
});
}
}
/*
* Handlers needs to be created only once per command to ensure they debounce with the given delay
*/
const handlersCache = new Map<string, (e: Event) => Promise<void>>();
const modal = new AnimatedModal({
dialogId: "commandLine",
@ -921,34 +896,24 @@ const modal = new AnimatedModal({
}
});
input.addEventListener(
"input",
debounce(100, async (e) => {
if (
inputModeParams === null ||
inputModeParams.command === null ||
!("validation" in inputModeParams.command)
) {
return;
}
input.addEventListener("input", 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
const handler = handlersCache.get(inputModeParams.command.id);
if (handler === undefined) {
throw new Error(
`Expected handler for command ${inputModeParams.command.id} is missing`
);
})
);
}
await handler(e);
});
modalEl.addEventListener("mousemove", (_e) => {
mouseMode = true;

View file

@ -1,6 +1,6 @@
import { Config } from "@monkeytype/schemas/configs";
import AnimatedModal from "../utils/animated-modal";
import { z } from "zod";
import { Validation } from "../elements/input-validation";
// this file is needed becauase otherwise it would produce a circular dependency
@ -47,21 +47,7 @@ export type CommandWithValidation<T> = (T extends string
* 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>;
};
validation: Validation<T>;
};
export type CommandsSubgroup = {

View file

@ -74,6 +74,7 @@ export class InputIndicator {
}
$(this.inputElement).css("padding-right", "2.1em");
this.parentElement.attr("data-indicator-status", optionId);
}
get(): keyof typeof this.options | null {

View file

@ -0,0 +1,252 @@
import { debounce } from "throttle-debounce";
import { z, ZodType } from "zod";
import { InputIndicator } from "./input-indicator";
import {
ConfigKey,
ConfigSchema,
Config as ConfigType,
} from "@monkeytype/schemas/configs";
import Config, * as UpdateConfig from "../config";
import * as Notifications from "../elements/notifications";
export type ValidationResult = {
status: "checking" | "success" | "failed";
errorMessage?: string;
};
export type Validation<T> = {
/**
* 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>;
/** custom debounce delay for `isValid` call. defaults to 100 */
debounceDelay?: number;
};
/**
* Create input handler for validated input element.
* the `callback` is called for each validation state change, including "checking".
* @param callback callback to call for each change of the validation status
* @param validation validation options
* @param inputValueConvert convert method from string to the schema type, mandatory if the schema is not a string schema
* @returns debounced input event handler
*/
export function createInputEventHandler<T>(
callback: (result: ValidationResult) => void,
validation: Validation<T>,
inputValueConvert?: (val: string) => T
): (e: Event) => Promise<void> {
let callIsValid =
validation.isValid !== undefined
? debounce(
validation.debounceDelay ?? 100,
async (
originalInput: HTMLInputElement,
currentValue: string,
checkValue: T
) => {
const result = await validation.isValid?.(checkValue);
if (originalInput.value !== currentValue) {
//value has change in the meantime, discard result
return;
}
if (result === true) {
callback({ status: "success" });
} else {
callback({ status: "failed", errorMessage: result });
}
}
)
: undefined;
return async (e) => {
const originalInput = e.target as HTMLInputElement;
const currentValue = originalInput.value;
let checkValue: unknown = currentValue;
if (inputValueConvert !== undefined) {
checkValue = inputValueConvert(currentValue);
}
callback({ status: "checking" });
if (validation.schema !== undefined) {
const schemaResult = validation.schema.safeParse(checkValue);
if (!schemaResult.success) {
callback({
status: "failed",
errorMessage: schemaResult.error.errors
.map((err) => err.message)
.join(", "),
});
return;
}
}
if (callIsValid === undefined) {
callback({ status: "success" });
//call original handler if defined
originalInput.oninput?.(e);
return;
}
callIsValid(originalInput, currentValue, checkValue as T);
//call original handler if defined
originalInput.oninput?.(e);
};
}
export type ValidationOptions<T> = (T extends string
? Validation<T>
: Validation<T> & {
/** convert string input. For `number`s use `Number` constructor */
inputValueConvert: (val: string) => T;
}) & {
/** optional callback is called for each change of the validation result */
callback?: (result: ValidationResult) => void;
};
/**
* adds an 'InputIndicator` to the given `inputElement` and updates its status depending on the given validation
* @param inputElement
* @param options
*/
export function validateWithIndicator<T>(
inputElement: HTMLInputElement,
options: ValidationOptions<T>
): void {
//use indicator
const indicator = new InputIndicator(inputElement, {
success: {
icon: "fa-check",
level: 1,
},
failed: {
icon: "fa-times",
level: -1,
},
checking: {
icon: "fa-circle-notch",
spinIcon: true,
level: 0,
},
});
const callback = (result: ValidationResult): void => {
if (result.status === "failed") {
indicator.show(result.status, result.errorMessage);
} else {
indicator.show(result.status);
}
options.callback?.(result);
};
const handler = createInputEventHandler(
callback,
options,
"inputValueConvert" in options ? options.inputValueConvert : undefined
);
inputElement.addEventListener("input", handler);
}
export type ConfigInputOptions<K extends ConfigKey, T = ConfigType[K]> = {
input: HTMLInputElement | null;
configName: K;
validation?: (T extends string
? Omit<Validation<T>, "schema">
: Omit<Validation<T>, "schema"> & {
inputValueConvert: (val: string) => T;
}) & {
/**set to `true` to validate against the `ConfigSchema` */
schema: boolean;
/** optional callback is called for each change of the validation result */
validationCallback?: (result: ValidationResult) => void;
};
};
/**
* Adds input event listeners to the given input element. On `focusOut` and when pressing `Enter` the current value is stored in the Config using `genericSet`.
* Note: Config is not updated if the value has not changed.
*
* If validation is set, Adds input validation using `InputIndicator` to the given input element. Config is only updated if the value is valid.
*
*/
export function handleConfigInput<T extends ConfigKey>({
input,
configName,
validation,
}: ConfigInputOptions<T, ConfigType[T]>): void {
if (input === null) {
throw new Error(`Failed to find input element for ${configName}`);
}
const inputValueConvert =
validation !== undefined && "inputValueConvert" in validation
? validation.inputValueConvert
: undefined;
let status: ValidationResult["status"] = "checking";
if (validation !== undefined) {
const schema = ConfigSchema.shape[configName] as ZodType;
validateWithIndicator(input, {
schema: validation.schema ? schema : undefined,
//@ts-expect-error this is fine
isValid: validation.isValid,
inputValueConvert,
callback: (result) => {
status = result.status;
},
});
}
const handleStore = (): void => {
if (input.value === "") {
//use last config value, clear validation
input.value = new String(Config[configName]).toString();
input.dispatchEvent(new Event("input"));
}
if (status === "failed") {
const parent = $(input.parentElement as HTMLElement);
parent
.stop(true, true)
.addClass("hasError")
.animate({ undefined: 1 }, 500, () => {
parent.removeClass("hasError");
});
return;
}
const value = (inputValueConvert?.(input.value) ??
input.value) as ConfigType[T];
if (Config[configName] === value) {
return;
}
const didConfigSave = UpdateConfig.genericSet(configName, value, false);
if (didConfigSave) {
Notifications.add("Saved", 1, {
duration: 1,
});
}
};
input.addEventListener("keypress", (e) => {
if (e.key === "Enter") {
handleStore();
}
});
input.addEventListener("focusout", (e) => handleStore());
}

View file

@ -1,31 +1,68 @@
import { ConfigValue } from "@monkeytype/schemas/configs";
import { Config as ConfigType, ConfigKey } from "@monkeytype/schemas/configs";
import Config from "../../config";
import * as Notifications from "../notifications";
import SlimSelect from "slim-select";
import { debounce } from "throttle-debounce";
import {
handleConfigInput,
ConfigInputOptions,
Validation,
} from "../input-validation";
type Mode = "select" | "button" | "range";
type Mode = "select" | "button" | "range" | "input";
export default class SettingsGroup<T extends ConfigValue> {
public configName: string;
export type SimpleValidation<T> = Omit<Validation<T>, "schema"> & {
schema?: true;
};
export default class SettingsGroup<K extends ConfigKey, T = ConfigType[K]> {
public configName: K;
public configFunction: (param: T, nosave?: boolean) => boolean;
public mode: Mode;
public setCallback?: () => void;
public updateCallback?: () => void;
private elements: Element[];
private validation?: T extends string
? SimpleValidation<T>
: SimpleValidation<T> & {
inputValueConvert: (val: string) => T;
};
constructor(
configName: string,
configName: K,
configFunction: (param: T, nosave?: boolean) => boolean,
mode: Mode,
setCallback?: () => void,
updateCallback?: () => void
options?: {
setCallback?: () => void;
updateCallback?: () => void;
validation?: T extends string
? SimpleValidation<T>
: SimpleValidation<T> & {
inputValueConvert: (val: string) => T;
};
}
) {
this.configName = configName;
this.mode = mode;
this.configFunction = configFunction;
this.setCallback = setCallback;
this.updateCallback = updateCallback;
this.setCallback = options?.setCallback;
this.updateCallback = options?.updateCallback;
this.validation = options?.validation;
const convertValue = (value: string): T => {
let typed = value as T;
if (
this.validation !== undefined &&
"inputValueConvert" in this.validation
) {
typed = this.validation.inputValueConvert(value);
}
if (typed === "true") typed = true as T;
if (typed === "false") typed = false as T;
return typed;
};
if (this.mode === "select") {
const el = document.querySelector(
@ -68,8 +105,9 @@ export default class SettingsGroup<T extends ConfigValue> {
) {
return;
}
const value = button.getAttribute("data-config-value");
if (value === undefined || value === "") {
let value = button.getAttribute("data-config-value");
if (value === null || value === "") {
console.error(
`Failed to handle settings button click for ${configName}: data-${configName} is missing or empty.`
);
@ -79,14 +117,39 @@ export default class SettingsGroup<T extends ConfigValue> {
);
return;
}
let typed = value as T;
if (typed === "true") typed = true as T;
if (typed === "false") typed = false as T;
let typed = convertValue(value);
this.setValue(typed);
});
}
this.elements = Array.from(els);
} else if (this.mode === "input") {
const input: HTMLInputElement | null = document.querySelector(`
.pageSettings .section[data-config-name=${this.configName}] .inputs .inputAndButton input`);
if (input === null) {
throw new Error(`Failed to find input element for ${configName}`);
}
let validation;
if (this.validation !== undefined) {
validation = {
schema: this.validation.schema ?? false,
isValid: this.validation.isValid,
inputValueConvert:
"inputValueConvert" in this.validation
? this.validation.inputValueConvert
: undefined,
};
}
handleConfigInput({
input,
configName: this.configName,
validation,
} as ConfigInputOptions<K>);
this.elements = [input];
} else if (this.mode === "range") {
const el = document.querySelector(
`.pageSettings .section[data-config-name=${this.configName}] input[type=range]`
@ -126,18 +189,18 @@ export default class SettingsGroup<T extends ConfigValue> {
this.updateUI();
}
setValue(value: T): void {
if (Config[this.configName as keyof typeof Config] === value) {
return;
setValue(value: T): boolean {
if (Config[this.configName] === value) {
return false;
}
this.configFunction(value);
const didSet = this.configFunction(value);
this.updateUI();
if (this.setCallback) this.setCallback();
return didSet;
}
updateUI(valueOverride?: T): void {
const newValue =
valueOverride ?? (Config[this.configName as keyof typeof Config] as T);
const newValue = valueOverride ?? (Config[this.configName] as T);
if (this.mode === "select") {
const select = this.elements?.[0] as HTMLSelectElement | null | undefined;

View file

@ -478,6 +478,7 @@ list.updateName = new SimpleModal({
return checkNameResponse === 200 ? true : "Name not available";
},
debounceDelay: 1000,
},
},
],

File diff suppressed because it is too large Load diff

View file

@ -4,8 +4,12 @@ import { format as dateFormat } from "date-fns/format";
import * as Loader from "../elements/loader";
import * as Notifications from "../elements/notifications";
import * as ConnectionState from "../states/connection";
import { InputIndicator } from "../elements/input-indicator";
import { debounce } from "throttle-debounce";
import {
Validation,
ValidationOptions,
ValidationResult,
validateWithIndicator as withValidation,
} from "../elements/input-validation";
type CommonInput<TType, TValue> = {
type: TType;
@ -21,12 +25,7 @@ type CommonInput<TType, TValue> = {
* 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?: Zod.Schema<TValue>;
validation?: Omit<Validation<string>, "isValid"> & {
/**
* Custom async validation method.
* This is intended to be used for validations that cannot be handled with a Zod schema like server-side validations.
@ -90,7 +89,7 @@ export type ExecReturn = {
};
type FormInput = CommonInputType & {
indicator?: InputIndicator;
hasError?: boolean;
currentValue: () => string;
};
type SimpleModalOptions = {
@ -327,70 +326,23 @@ export class SimpleModal {
};
if (input.validation !== undefined) {
const indicator = new InputIndicator(element, {
valid: {
icon: "fa-check",
level: 1,
const options: ValidationOptions<string> = {
schema: input.validation.schema ?? undefined,
isValid:
input.validation.isValid !== undefined
? async (val: string) => {
//@ts-expect-error this is fine
return input.validation.isValid(val, this);
}
: undefined,
callback: (result: ValidationResult) => {
input.hasError = result.status !== "success";
},
invalid: {
icon: "fa-times",
level: -1,
},
checking: {
icon: "fa-circle-notch",
spinIcon: true,
level: 0,
},
});
input.indicator = indicator;
const debouceIsValid = debounce(1000, async (value: string) => {
const result = await input.validation?.isValid?.(value, this);
if (element.value !== value) {
//value of the input has changed in the meantime. discard
return;
}
if (result === true) {
indicator.show("valid");
} else {
indicator.show("invalid", result);
}
});
const validateInput = async (value: string): Promise<void> => {
if (value === undefined || value === "") {
indicator.hide();
return;
}
if (input.validation?.schema !== undefined) {
const schemaResult = input.validation.schema.safeParse(value);
if (!schemaResult.success) {
indicator.show(
"invalid",
schemaResult.error.errors.map((err) => err.message).join(", ")
);
return;
}
}
if (input.validation?.isValid !== undefined) {
indicator.show("checking");
debouceIsValid(value);
return;
}
indicator.show("valid");
debounceDelay: input.validation.debounceDelay,
};
element.oninput = async (event) => {
const value = (event.target as HTMLInputElement).value;
await validateInput(value);
//call original handler if defined
input.oninput?.(event);
};
withValidation(element, options);
}
});
@ -399,7 +351,6 @@ export class SimpleModal {
exec(): void {
if (!this.canClose) return;
if (
this.inputs
.filter((i) => i.hidden !== true && i.optional !== true)
@ -409,7 +360,7 @@ export class SimpleModal {
return;
}
if (this.inputs.some((i) => i.indicator?.get() === "invalid")) {
if (this.inputs.some((i) => i.hasError === true)) {
Notifications.add("Please solve all validation errors", 0);
return;
}