diff --git a/frontend/src/html/pages/settings.html b/frontend/src/html/pages/settings.html index c77a28756..43f57ed6f 100644 --- a/frontend/src/html/pages/settings.html +++ b/frontend/src/html/pages/settings.html @@ -256,9 +256,6 @@ step="1" value="" /> -
@@ -288,9 +285,6 @@ step="1" value="" /> -
@@ -322,9 +316,6 @@ step="1" value="" /> -
@@ -792,9 +783,6 @@ step="1" value="" /> -
@@ -1020,9 +1008,6 @@ min="10" max="90" /> -
@@ -1131,9 +1116,6 @@ class="input" min="0" /> - @@ -1154,9 +1136,6 @@ class="input" tabindex="0" /> - diff --git a/frontend/src/styles/settings.scss b/frontend/src/styles/settings.scss index b69433b8a..db93648cc 100644 --- a/frontend/src/styles/settings.scss +++ b/frontend/src/styles/settings.scss @@ -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 { diff --git a/frontend/src/ts/commandline/commandline.ts b/frontend/src/ts/commandline/commandline.ts index d05b1e229..aa6f88f18 100644 --- a/frontend/src/ts/commandline/commandline.ts +++ b/frontend/src/ts/commandline/commandline.ts @@ -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 { value: command.defaultValue?.() ?? "", icon: command.icon ?? "fa-chevron-right", }; + if ("validation" in command && !handlersCache.has(command.id)) { + const commandWithValidation = command as CommandWithValidation; + 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["validation"] -): Promise { - 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 Promise>(); 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; - - 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; diff --git a/frontend/src/ts/commandline/types.ts b/frontend/src/ts/commandline/types.ts index ad1c7ecbf..ed4de15d2 100644 --- a/frontend/src/ts/commandline/types.ts +++ b/frontend/src/ts/commandline/types.ts @@ -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 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; - /** - * 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; - }; + validation: Validation; }; export type CommandsSubgroup = { diff --git a/frontend/src/ts/elements/input-indicator.ts b/frontend/src/ts/elements/input-indicator.ts index 6bdca8eb2..ab32e754a 100644 --- a/frontend/src/ts/elements/input-indicator.ts +++ b/frontend/src/ts/elements/input-indicator.ts @@ -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 { diff --git a/frontend/src/ts/elements/input-validation.ts b/frontend/src/ts/elements/input-validation.ts new file mode 100644 index 000000000..db71dde3e --- /dev/null +++ b/frontend/src/ts/elements/input-validation.ts @@ -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 = { + /** + * Zod schema to validate the input value against. + * The indicator will show the error messages from the schema. + */ + schema?: z.Schema; + + /** + * 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; + + /** 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( + callback: (result: ValidationResult) => void, + validation: Validation, + inputValueConvert?: (val: string) => T +): (e: Event) => Promise { + 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 extends string + ? Validation + : Validation & { + /** 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( + inputElement: HTMLInputElement, + options: ValidationOptions +): 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 = { + input: HTMLInputElement | null; + configName: K; + validation?: (T extends string + ? Omit, "schema"> + : Omit, "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({ + input, + configName, + validation, +}: ConfigInputOptions): 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()); +} diff --git a/frontend/src/ts/elements/settings/settings-group.ts b/frontend/src/ts/elements/settings/settings-group.ts index e093626a2..c590bda6d 100644 --- a/frontend/src/ts/elements/settings/settings-group.ts +++ b/frontend/src/ts/elements/settings/settings-group.ts @@ -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 { - public configName: string; +export type SimpleValidation = Omit, "schema"> & { + schema?: true; +}; + +export default class SettingsGroup { + 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 + : SimpleValidation & { + 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 + : SimpleValidation & { + 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 { ) { 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 { ); 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); + + 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 { 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; diff --git a/frontend/src/ts/modals/simple-modals.ts b/frontend/src/ts/modals/simple-modals.ts index 30a36ea45..df8442f9c 100644 --- a/frontend/src/ts/modals/simple-modals.ts +++ b/frontend/src/ts/modals/simple-modals.ts @@ -478,6 +478,7 @@ list.updateName = new SimpleModal({ return checkNameResponse === 200 ? true : "Name not available"; }, + debounceDelay: 1000, }, }, ], diff --git a/frontend/src/ts/pages/settings.ts b/frontend/src/ts/pages/settings.ts index 260b18c7b..1fc9eed60 100644 --- a/frontend/src/ts/pages/settings.ts +++ b/frontend/src/ts/pages/settings.ts @@ -20,12 +20,12 @@ import SlimSelect from "slim-select"; import * as Skeleton from "../utils/skeleton"; import * as CustomBackgroundFilter from "../elements/custom-background-filter"; import { - ConfigValue, CustomBackgroundSchema, ThemeName, CustomLayoutFluid, FunboxName, ConfigKeySchema, + ConfigKey, } from "@monkeytype/schemas/configs"; import { getAllFunboxes, checkCompatibility } from "@monkeytype/funbox"; import { getActiveFunboxNames } from "../test/funbox/list"; @@ -39,14 +39,15 @@ import { LayoutName } from "@monkeytype/schemas/layouts"; import { LanguageGroupNames, LanguageGroups } from "../constants/languages"; import { Language } from "@monkeytype/schemas/languages"; import { z } from "zod"; +import { handleConfigInput } from "../elements/input-validation"; let settingsInitialized = false; -type SettingsGroups = Record>; +type SettingsGroups = Partial<{ [K in ConfigKey]: SettingsGroup }>; let customLayoutFluidSelect: SlimSelect | undefined; let customPolyglotSelect: SlimSelect | undefined; -export const groups: SettingsGroups = {}; +export const groups: SettingsGroups = {}; const HighlightSchema = ConfigKeySchema.or( z.enum([ @@ -71,396 +72,407 @@ async function initGroups(): Promise { "smoothCaret", UpdateConfig.setSmoothCaret, "button" - ) as SettingsGroup; + ); groups["codeUnindentOnBackspace"] = new SettingsGroup( "codeUnindentOnBackspace", UpdateConfig.setCodeUnindentOnBackspace, "button" - ) as SettingsGroup; + ); groups["difficulty"] = new SettingsGroup( "difficulty", UpdateConfig.setDifficulty, "button" - ) as SettingsGroup; + ); groups["quickRestart"] = new SettingsGroup( "quickRestart", UpdateConfig.setQuickRestartMode, "button" - ) as SettingsGroup; + ); groups["showAverage"] = new SettingsGroup( "showAverage", UpdateConfig.setShowAverage, "button" - ) as SettingsGroup; + ); groups["keymapMode"] = new SettingsGroup( "keymapMode", UpdateConfig.setKeymapMode, "button", - () => { - groups["showLiveWpm"]?.updateUI(); - }, - () => { - if (Config.keymapMode === "off") { - $(".pageSettings .section[data-config-name='keymapStyle']").addClass( - "hidden" - ); - $(".pageSettings .section[data-config-name='keymapLayout']").addClass( - "hidden" - ); - $( - ".pageSettings .section[data-config-name='keymapLegendStyle']" - ).addClass("hidden"); - $( - ".pageSettings .section[data-config-name='keymapShowTopRow']" - ).addClass("hidden"); - $(".pageSettings .section[data-config-name='keymapSize']").addClass( - "hidden" - ); - } else { - $(".pageSettings .section[data-config-name='keymapStyle']").removeClass( - "hidden" - ); - $( - ".pageSettings .section[data-config-name='keymapLayout']" - ).removeClass("hidden"); - $( - ".pageSettings .section[data-config-name='keymapLegendStyle']" - ).removeClass("hidden"); - $( - ".pageSettings .section[data-config-name='keymapShowTopRow']" - ).removeClass("hidden"); - $(".pageSettings .section[data-config-name='keymapSize']").removeClass( - "hidden" - ); - } + { + updateCallback: () => { + if (Config.keymapMode === "off") { + $(".pageSettings .section[data-config-name='keymapStyle']").addClass( + "hidden" + ); + $(".pageSettings .section[data-config-name='keymapLayout']").addClass( + "hidden" + ); + $( + ".pageSettings .section[data-config-name='keymapLegendStyle']" + ).addClass("hidden"); + $( + ".pageSettings .section[data-config-name='keymapShowTopRow']" + ).addClass("hidden"); + $(".pageSettings .section[data-config-name='keymapSize']").addClass( + "hidden" + ); + } else { + $( + ".pageSettings .section[data-config-name='keymapStyle']" + ).removeClass("hidden"); + $( + ".pageSettings .section[data-config-name='keymapLayout']" + ).removeClass("hidden"); + $( + ".pageSettings .section[data-config-name='keymapLegendStyle']" + ).removeClass("hidden"); + $( + ".pageSettings .section[data-config-name='keymapShowTopRow']" + ).removeClass("hidden"); + $( + ".pageSettings .section[data-config-name='keymapSize']" + ).removeClass("hidden"); + } + }, } - ) as SettingsGroup; - groups["keymapMatrix"] = new SettingsGroup( + ); + groups["keymapStyle"] = new SettingsGroup( "keymapStyle", UpdateConfig.setKeymapStyle, "button" - ) as SettingsGroup; + ); groups["keymapLayout"] = new SettingsGroup( "keymapLayout", UpdateConfig.setKeymapLayout, "select" - ) as SettingsGroup; + ); groups["keymapLegendStyle"] = new SettingsGroup( "keymapLegendStyle", UpdateConfig.setKeymapLegendStyle, "button" - ) as SettingsGroup; + ); groups["keymapShowTopRow"] = new SettingsGroup( "keymapShowTopRow", UpdateConfig.setKeymapShowTopRow, "button" - ) as SettingsGroup; + ); groups["keymapSize"] = new SettingsGroup( "keymapSize", UpdateConfig.setKeymapSize, "range" - ) as SettingsGroup; + ); groups["showKeyTips"] = new SettingsGroup( "showKeyTips", UpdateConfig.setKeyTips, "button" - ) as SettingsGroup; + ); groups["freedomMode"] = new SettingsGroup( "freedomMode", UpdateConfig.setFreedomMode, "button", - () => { - groups["confidenceMode"]?.updateUI(); + { + setCallback: () => { + groups["confidenceMode"]?.updateUI(); + }, } - ) as SettingsGroup; + ); groups["strictSpace"] = new SettingsGroup( "strictSpace", UpdateConfig.setStrictSpace, "button" - ) as SettingsGroup; + ); groups["oppositeShiftMode"] = new SettingsGroup( "oppositeShiftMode", UpdateConfig.setOppositeShiftMode, "button" - ) as SettingsGroup; + ); groups["confidenceMode"] = new SettingsGroup( "confidenceMode", UpdateConfig.setConfidenceMode, "button", - () => { - groups["freedomMode"]?.updateUI(); - groups["stopOnError"]?.updateUI(); + { + setCallback: () => { + groups["freedomMode"]?.updateUI(); + groups["stopOnError"]?.updateUI(); + }, } - ) as SettingsGroup; + ); groups["indicateTypos"] = new SettingsGroup( "indicateTypos", UpdateConfig.setIndicateTypos, "button" - ) as SettingsGroup; + ); groups["hideExtraLetters"] = new SettingsGroup( "hideExtraLetters", UpdateConfig.setHideExtraLetters, "button" - ) as SettingsGroup; + ); groups["blindMode"] = new SettingsGroup( "blindMode", UpdateConfig.setBlindMode, "button" - ) as SettingsGroup; + ); groups["quickEnd"] = new SettingsGroup( "quickEnd", UpdateConfig.setQuickEnd, "button" - ) as SettingsGroup; + ); groups["repeatQuotes"] = new SettingsGroup( "repeatQuotes", UpdateConfig.setRepeatQuotes, "button" - ) as SettingsGroup; - groups["ads"] = new SettingsGroup( - "ads", - UpdateConfig.setAds, - "button" - ) as SettingsGroup; + ); + groups["ads"] = new SettingsGroup("ads", UpdateConfig.setAds, "button"); groups["alwaysShowWordsHistory"] = new SettingsGroup( "alwaysShowWordsHistory", UpdateConfig.setAlwaysShowWordsHistory, "button" - ) as SettingsGroup; + ); groups["britishEnglish"] = new SettingsGroup( "britishEnglish", UpdateConfig.setBritishEnglish, "button" - ) as SettingsGroup; + ); groups["singleListCommandLine"] = new SettingsGroup( "singleListCommandLine", UpdateConfig.setSingleListCommandLine, "button" - ) as SettingsGroup; + ); groups["capsLockWarning"] = new SettingsGroup( "capsLockWarning", UpdateConfig.setCapsLockWarning, "button" - ) as SettingsGroup; + ); groups["flipTestColors"] = new SettingsGroup( "flipTestColors", UpdateConfig.setFlipTestColors, "button" - ) as SettingsGroup; + ); groups["showOutOfFocusWarning"] = new SettingsGroup( "showOutOfFocusWarning", UpdateConfig.setShowOutOfFocusWarning, "button" - ) as SettingsGroup; + ); groups["colorfulMode"] = new SettingsGroup( "colorfulMode", UpdateConfig.setColorfulMode, "button" - ) as SettingsGroup; + ); groups["startGraphsAtZero"] = new SettingsGroup( "startGraphsAtZero", UpdateConfig.setStartGraphsAtZero, "button" - ) as SettingsGroup; + ); groups["autoSwitchTheme"] = new SettingsGroup( "autoSwitchTheme", UpdateConfig.setAutoSwitchTheme, "button" - ) as SettingsGroup; + ); groups["randomTheme"] = new SettingsGroup( "randomTheme", UpdateConfig.setRandomTheme, "button" - ) as SettingsGroup; + ); groups["stopOnError"] = new SettingsGroup( "stopOnError", UpdateConfig.setStopOnError, "button", - () => { - groups["confidenceMode"]?.updateUI(); + { + setCallback: () => { + groups["confidenceMode"]?.updateUI(); + }, } - ) as SettingsGroup; + ); groups["soundVolume"] = new SettingsGroup( "soundVolume", UpdateConfig.setSoundVolume, "range" - ) as SettingsGroup; + ); groups["playTimeWarning"] = new SettingsGroup( "playTimeWarning", UpdateConfig.setPlayTimeWarning, "button", - () => { - if (Config.playTimeWarning !== "off") void Sound.playTimeWarning(); + { + setCallback: () => { + if (Config.playTimeWarning !== "off") void Sound.playTimeWarning(); + }, } - ) as SettingsGroup; + ); groups["playSoundOnError"] = new SettingsGroup( "playSoundOnError", UpdateConfig.setPlaySoundOnError, "button", - () => { - if (Config.playSoundOnError !== "off") void Sound.playError(); + { + setCallback: () => { + if (Config.playSoundOnError !== "off") void Sound.playError(); + }, } - ) as SettingsGroup; + ); groups["playSoundOnClick"] = new SettingsGroup( "playSoundOnClick", UpdateConfig.setPlaySoundOnClick, "button", - () => { - if (Config.playSoundOnClick !== "off") void Sound.playClick("KeyQ"); + { + setCallback: () => { + if (Config.playSoundOnClick !== "off") void Sound.playClick("KeyQ"); + }, } - ) as SettingsGroup; + ); groups["showAllLines"] = new SettingsGroup( "showAllLines", UpdateConfig.setShowAllLines, "button" - ) as SettingsGroup; + ); groups["paceCaret"] = new SettingsGroup( "paceCaret", UpdateConfig.setPaceCaret, "button" - ) as SettingsGroup; + ); groups["repeatedPace"] = new SettingsGroup( "repeatedPace", UpdateConfig.setRepeatedPace, "button" - ) as SettingsGroup; + ); groups["minWpm"] = new SettingsGroup( "minWpm", UpdateConfig.setMinWpm, "button" - ) as SettingsGroup; + ); groups["minAcc"] = new SettingsGroup( "minAcc", UpdateConfig.setMinAcc, "button" - ) as SettingsGroup; + ); groups["minBurst"] = new SettingsGroup( "minBurst", UpdateConfig.setMinBurst, "button" - ) as SettingsGroup; + ); groups["smoothLineScroll"] = new SettingsGroup( "smoothLineScroll", UpdateConfig.setSmoothLineScroll, "button" - ) as SettingsGroup; + ); groups["lazyMode"] = new SettingsGroup( "lazyMode", UpdateConfig.setLazyMode, "button" - ) as SettingsGroup; + ); groups["layout"] = new SettingsGroup( "layout", UpdateConfig.setLayout, "select" - ) as SettingsGroup; + ); groups["language"] = new SettingsGroup( "language", UpdateConfig.setLanguage, "select" - ) as SettingsGroup; + ); groups["fontSize"] = new SettingsGroup( "fontSize", UpdateConfig.setFontSize, - "button" - ) as SettingsGroup; + "input", + { validation: { schema: true, inputValueConvert: Number } } + ); groups["maxLineWidth"] = new SettingsGroup( "maxLineWidth", UpdateConfig.setMaxLineWidth, - "button" - ) as SettingsGroup; + "input", + { validation: { schema: true, inputValueConvert: Number } } + ); groups["caretStyle"] = new SettingsGroup( "caretStyle", UpdateConfig.setCaretStyle, "button" - ) as SettingsGroup; + ); groups["paceCaretStyle"] = new SettingsGroup( "paceCaretStyle", UpdateConfig.setPaceCaretStyle, "button" - ) as SettingsGroup; + ); groups["timerStyle"] = new SettingsGroup( "timerStyle", UpdateConfig.setTimerStyle, "button" - ) as SettingsGroup; + ); groups["liveSpeedStyle"] = new SettingsGroup( "liveSpeedStyle", UpdateConfig.setLiveSpeedStyle, "button" - ) as SettingsGroup; + ); groups["liveAccStyle"] = new SettingsGroup( "liveAccStyle", UpdateConfig.setLiveAccStyle, "button" - ) as SettingsGroup; + ); groups["liveBurstStyle"] = new SettingsGroup( "liveBurstStyle", UpdateConfig.setLiveBurstStyle, "button" - ) as SettingsGroup; + ); groups["highlightMode"] = new SettingsGroup( "highlightMode", UpdateConfig.setHighlightMode, "button" - ) as SettingsGroup; + ); groups["tapeMode"] = new SettingsGroup( "tapeMode", UpdateConfig.setTapeMode, "button" - ) as SettingsGroup; + ); groups["tapeMargin"] = new SettingsGroup( "tapeMargin", UpdateConfig.setTapeMargin, - "button" - ) as SettingsGroup; + "input", + { validation: { schema: true, inputValueConvert: Number } } + ); groups["timerOpacity"] = new SettingsGroup( "timerOpacity", UpdateConfig.setTimerOpacity, "button" - ) as SettingsGroup; + ); groups["timerColor"] = new SettingsGroup( "timerColor", UpdateConfig.setTimerColor, "button" - ) as SettingsGroup; + ); groups["fontFamily"] = new SettingsGroup( "fontFamily", UpdateConfig.setFontFamily, "button", - undefined, - () => { - const customButton = $( - ".pageSettings .section[data-config-name='fontFamily'] .buttons button[data-config-value='custom']" - ); + { + updateCallback: () => { + const customButton = $( + ".pageSettings .section[data-config-name='fontFamily'] .buttons button[data-config-value='custom']" + ); - if ( - $( - ".pageSettings .section[data-config-name='fontFamily'] .buttons .active" - ).length === 0 - ) { - customButton.addClass("active"); - customButton.text(`Custom (${Config.fontFamily.replace(/_/g, " ")})`); - } else { - customButton.text("Custom"); - } + if ( + $( + ".pageSettings .section[data-config-name='fontFamily'] .buttons .active" + ).length === 0 + ) { + customButton.addClass("active"); + customButton.text(`Custom (${Config.fontFamily.replace(/_/g, " ")})`); + } else { + customButton.text("Custom"); + } + }, } - ) as SettingsGroup; + ); groups["alwaysShowDecimalPlaces"] = new SettingsGroup( "alwaysShowDecimalPlaces", UpdateConfig.setAlwaysShowDecimalPlaces, "button" - ) as SettingsGroup; + ); groups["typingSpeedUnit"] = new SettingsGroup( "typingSpeedUnit", UpdateConfig.setTypingSpeedUnit, "button" - ) as SettingsGroup; + ); groups["customBackgroundSize"] = new SettingsGroup( "customBackgroundSize", UpdateConfig.setCustomBackgroundSize, "button" - ) as SettingsGroup; + ); } async function fillSettingsPage(): Promise { @@ -641,6 +653,55 @@ async function fillSettingsPage(): Promise { }, }); + handleConfigInput({ + input: document.querySelector( + ".pageSettings .section[data-config-name='minWpm'] input" + ), + configName: "minWpmCustomSpeed", + validation: { + schema: true, + inputValueConvert: (it) => + getTypingSpeedUnit(Config.typingSpeedUnit).toWpm( + new Number(it).valueOf() + ), + }, + }); + + handleConfigInput({ + input: document.querySelector( + ".pageSettings .section[data-config-name='minAcc'] input" + ), + configName: "minAccCustom", + validation: { + schema: true, + inputValueConvert: Number, + }, + }); + + handleConfigInput({ + input: document.querySelector( + ".pageSettings .section[data-config-name='minBurst'] input" + ), + configName: "minBurstCustomSpeed", + validation: { + schema: true, + inputValueConvert: (it) => + getTypingSpeedUnit(Config.typingSpeedUnit).toWpm( + new Number(it).valueOf() + ), + }, + }); + + handleConfigInput({ + input: document.querySelector( + ".pageSettings .section[data-config-name='paceCaret'] input" + ), + configName: "paceCaretCustomSpeed", + validation: { + schema: true, + inputValueConvert: Number, + }, + }); setEventDisabled(true); await initGroups(); @@ -749,15 +810,21 @@ function refreshPresetsSettingsSection(): void { } } -export async function update(): Promise { +export async function update( + options: { + eventKey?: ConfigEvent.ConfigEventKey; + } = {} +): Promise { if (Config.showKeyTips) { $(".pageSettings .tip").removeClass("hidden"); } else { $(".pageSettings .tip").addClass("hidden"); } - for (const group of Object.keys(groups)) { - groups[group]?.updateUI(); + for (const group of Object.values(groups)) { + if ("updateUI" in group) { + group.updateUI(); + } } refreshTagsSettingsSection(); @@ -769,25 +836,45 @@ export async function update(): Promise { ThemePicker.setCustomInputs(true); // ThemePicker.updateActiveButton(); - $( - ".pageSettings .section[data-config-name='paceCaret'] input.customPaceCaretSpeed" - ).val( + const setInputValue = ( + key: ConfigKey, + query: string, + value: string | number + ): void => { + if (options.eventKey === undefined || options.eventKey === key) { + const element = document.querySelector(query) as HTMLInputElement; + if (element === null) { + throw new Error("Unknown input element " + query); + } + + element.value = new String(value).toString(); + element.dispatchEvent(new Event("input")); + } + }; + + setInputValue( + "paceCaret", + ".pageSettings .section[data-config-name='paceCaret'] input.customPaceCaretSpeed", getTypingSpeedUnit(Config.typingSpeedUnit).fromWpm( Config.paceCaretCustomSpeed ) ); - $( - ".pageSettings .section[data-config-name='minWpm'] input.customMinWpmSpeed" - ).val( + setInputValue( + "minWpmCustomSpeed", + ".pageSettings .section[data-config-name='minWpm'] input.customMinWpmSpeed", getTypingSpeedUnit(Config.typingSpeedUnit).fromWpm(Config.minWpmCustomSpeed) ); - $(".pageSettings .section[data-config-name='minAcc'] input.customMinAcc").val( + + setInputValue( + "minAccCustom", + ".pageSettings .section[data-config-name='minAcc'] input.customMinAcc", Config.minAccCustom ); - $( - ".pageSettings .section[data-config-name='minBurst'] input.customMinBurst" - ).val( + + setInputValue( + "minBurstCustomSpeed", + ".pageSettings .section[data-config-name='minBurst'] input.customMinBurst", getTypingSpeedUnit(Config.typingSpeedUnit).fromWpm( Config.minBurstCustomSpeed ) @@ -814,25 +901,35 @@ export async function update(): Promise { } updateCustomBackgroundRemoveButtonVisibility(); - $(".pageSettings .section[data-config-name='fontSize'] input").val( + setInputValue( + "fontSize", + ".pageSettings .section[data-config-name='fontSize'] input", Config.fontSize ); - $(".pageSettings .section[data-config-name='maxLineWidth'] input").val( + setInputValue( + "maxLineWidth", + ".pageSettings .section[data-config-name='maxLineWidth'] input", Config.maxLineWidth ); - $(".pageSettings .section[data-config-name='keymapSize'] input").val( + setInputValue( + "keymapSize", + ".pageSettings .section[data-config-name='keymapSize'] input", Config.keymapSize ); - $(".pageSettings .section[data-config-name='tapeMargin'] input").val( + setInputValue( + "tapeMargin", + ".pageSettings .section[data-config-name='tapeMargin'] input", Config.tapeMargin ); - $( - ".pageSettings .section[data-config-name='customBackgroundSize'] input" - ).val(Config.customBackground); + setInputValue( + "customBackground", + ".pageSettings .section[data-config-name='customBackgroundSize'] input", + Config.customBackground + ); if (isAuthenticated()) { showAccountSection(); @@ -907,130 +1004,6 @@ function updateCustomBackgroundRemoveButtonVisibility(): void { } } -$(".pageSettings .section[data-config-name='paceCaret']").on( - "focusout", - "input.customPaceCaretSpeed", - () => { - const inputValue = parseInt( - $( - ".pageSettings .section[data-config-name='paceCaret'] input.customPaceCaretSpeed" - ).val() as string - ); - const newConfigValue = getTypingSpeedUnit(Config.typingSpeedUnit).toWpm( - inputValue - ); - UpdateConfig.setPaceCaretCustomSpeed(newConfigValue); - } -); - -$(".pageSettings .section[data-config-name='paceCaret']").on( - "click", - "button.save", - () => { - const inputValue = parseInt( - $( - ".pageSettings .section[data-config-name='paceCaret'] input.customPaceCaretSpeed" - ).val() as string - ); - const newConfigValue = getTypingSpeedUnit(Config.typingSpeedUnit).toWpm( - inputValue - ); - UpdateConfig.setPaceCaretCustomSpeed(newConfigValue); - } -); - -$(".pageSettings .section[data-config-name='minWpm']").on( - "focusout", - "input.customMinWpmSpeed", - () => { - const inputValue = parseInt( - $( - ".pageSettings .section[data-config-name='minWpm'] input.customMinWpmSpeed" - ).val() as string - ); - const newConfigValue = getTypingSpeedUnit(Config.typingSpeedUnit).toWpm( - inputValue - ); - UpdateConfig.setMinWpmCustomSpeed(newConfigValue); - } -); - -$(".pageSettings .section[data-config-name='minWpm']").on( - "click", - "button.save", - () => { - const inputValue = parseInt( - $( - ".pageSettings .section[data-config-name='minWpm'] input.customMinWpmSpeed" - ).val() as string - ); - const newConfigValue = getTypingSpeedUnit(Config.typingSpeedUnit).toWpm( - inputValue - ); - UpdateConfig.setMinWpmCustomSpeed(newConfigValue); - } -); - -$(".pageSettings .section[data-config-name='minAcc']").on( - "focusout", - "input.customMinAcc", - () => { - UpdateConfig.setMinAccCustom( - parseInt( - $( - ".pageSettings .section[data-config-name='minAcc'] input.customMinAcc" - ).val() as string - ) - ); - } -); - -$(".pageSettings .section[data-config-name='minAcc']").on( - "click", - "button.save", - () => { - UpdateConfig.setMinAccCustom( - parseInt( - $( - ".pageSettings .section[data-config-name='minAcc'] input.customMinAcc" - ).val() as string - ) - ); - } -); - -$(".pageSettings .section[data-config-name='minBurst']").on( - "focusout", - "input.customMinBurst", - () => { - const inputValue = parseInt( - $( - ".pageSettings .section[data-config-name='minBurst'] input.customMinBurst" - ).val() as string - ); - const newConfigValue = getTypingSpeedUnit(Config.typingSpeedUnit).toWpm( - inputValue - ); - UpdateConfig.setMinBurstCustomSpeed(newConfigValue); - } -); - -$(".pageSettings .section[data-config-name='minBurst']").on( - "click", - "button.save", - () => { - const inputValue = parseInt( - $( - ".pageSettings .section[data-config-name='minBurst'] input.customMinBurst" - ).val() as string - ); - const newConfigValue = getTypingSpeedUnit(Config.typingSpeedUnit).toWpm( - inputValue - ); - UpdateConfig.setMinBurstCustomSpeed(newConfigValue); - } -); - //funbox $(".pageSettings .section[data-config-name='funbox'] .buttons").on( "click", @@ -1133,131 +1106,6 @@ $( } }); -$( - ".pageSettings .section[data-config-name='fontSize'] .inputAndButton button.save" -).on("click", () => { - const didConfigSave = UpdateConfig.setFontSize( - parseFloat( - $( - ".pageSettings .section[data-config-name='fontSize'] .inputAndButton input" - ).val() as string - ) - ); - if (didConfigSave) { - Notifications.add("Saved", 1, { - duration: 1, - }); - } -}); - -$( - ".pageSettings .section[data-config-name='fontSize'] .inputAndButton input" -).on("keypress", (e) => { - if (e.key === "Enter") { - const didConfigSave = UpdateConfig.setFontSize( - parseFloat( - $( - ".pageSettings .section[data-config-name='fontSize'] .inputAndButton input" - ).val() as string - ) - ); - if (didConfigSave) { - Notifications.add("Saved", 1, { - duration: 1, - }); - } - } -}); - -$( - ".pageSettings .section[data-config-name='tapeMargin'] .inputAndButton button.save" -).on("click", () => { - const didConfigSave = UpdateConfig.setTapeMargin( - parseFloat( - $( - ".pageSettings .section[data-config-name='tapeMargin'] .inputAndButton input" - ).val() as string - ) - ); - if (didConfigSave) { - Notifications.add("Saved", 1, { - duration: 1, - }); - } -}); - -$( - ".pageSettings .section[data-config-name='tapeMargin'] .inputAndButton input" -).on("keypress", (e) => { - if (e.key === "Enter") { - const didConfigSave = UpdateConfig.setTapeMargin( - parseFloat( - $( - ".pageSettings .section[data-config-name='tapeMargin'] .inputAndButton input" - ).val() as string - ) - ); - if (didConfigSave) { - Notifications.add("Saved", 1, { - duration: 1, - }); - } - } -}); - -$( - ".pageSettings .section[data-config-name='maxLineWidth'] .inputAndButton button.save" -).on("click", () => { - const didConfigSave = UpdateConfig.setMaxLineWidth( - parseFloat( - $( - ".pageSettings .section[data-config-name='maxLineWidth'] .inputAndButton input" - ).val() as string - ) - ); - if (didConfigSave) { - Notifications.add("Saved", 1, { - duration: 1, - }); - } -}); - -$( - ".pageSettings .section[data-config-name='maxLineWidth'] .inputAndButton input" -).on("focusout", () => { - const didConfigSave = UpdateConfig.setMaxLineWidth( - parseFloat( - $( - ".pageSettings .section[data-config-name='maxLineWidth'] .inputAndButton input" - ).val() as string - ) - ); - if (didConfigSave) { - Notifications.add("Saved", 1, { - duration: 1, - }); - } -}); - -$( - ".pageSettings .section[data-config-name='maxLineWidth'] .inputAndButton input" -).on("keypress", (e) => { - if (e.key === "Enter") { - const didConfigSave = UpdateConfig.setMaxLineWidth( - parseFloat( - $( - ".pageSettings .section[data-config-name='maxLineWidth'] .inputAndButton input" - ).val() as string - ) - ); - if (didConfigSave) { - Notifications.add("Saved", 1, { - duration: 1, - }); - } - } -}); - $( ".pageSettings .section[data-config-name='keymapSize'] .inputAndButton button.save" ).on("click", () => { @@ -1421,7 +1269,7 @@ ConfigEvent.subscribe((eventKey, eventValue) => { //make sure the page doesnt update a billion times when applying a preset/config at once if (configEventDisabled || eventKey === "saveToLocalStorage") return; if (ActivePage.get() === "settings" && eventKey !== "theme") { - void update(); + void update({ eventKey }); } }); diff --git a/frontend/src/ts/utils/simple-modal.ts b/frontend/src/ts/utils/simple-modal.ts index 0679e75bc..bd5200ba7 100644 --- a/frontend/src/ts/utils/simple-modal.ts +++ b/frontend/src/ts/utils/simple-modal.ts @@ -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 = { type: TType; @@ -21,12 +25,7 @@ type CommonInput = { * 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; + validation?: Omit, "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 = { + 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 => { - 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; }