diff --git a/frontend/src/ts/db.ts b/frontend/src/ts/db.ts index 43623d33b19f..a034bd6242f2 100644 --- a/frontend/src/ts/db.ts +++ b/frontend/src/ts/db.ts @@ -217,7 +217,7 @@ export async function initSnapshot(): Promise { snap.tags = userData.tags?.map((tag) => ({ ...tag, - display: tag.name.replaceAll("_", " "), + display: tag.name.replace(/_/g, " "), })) ?? []; snap.tags = snap.tags?.sort((a, b) => { @@ -234,7 +234,7 @@ export async function initSnapshot(): Promise { const presetsWithDisplay = presetsData.map((preset) => { return { ...preset, - display: preset.name.replace(/_/gi, " "), + display: preset.name.replace(/_/g, " "), }; }) as SnapshotPreset[]; snap.presets = presetsWithDisplay; diff --git a/frontend/src/ts/elements/account/result-filters.ts b/frontend/src/ts/elements/account/result-filters.ts index cc1a78ea7065..e2ccf7ecc702 100644 --- a/frontend/src/ts/elements/account/result-filters.ts +++ b/frontend/src/ts/elements/account/result-filters.ts @@ -187,7 +187,6 @@ function addFilterPresetToSnapshot(filter: ResultFilters): void { export async function createFilterPreset( name: string, ): Promise { - name = name.replace(/ /g, "_"); showLoaderBar(); const result = await Ape.users.addResultFilterPreset({ body: { ...filters, name }, diff --git a/frontend/src/ts/modals/edit-preset.ts b/frontend/src/ts/modals/edit-preset.ts index de8d82d4b574..6b4d3f6e54d7 100644 --- a/frontend/src/ts/modals/edit-preset.ts +++ b/frontend/src/ts/modals/edit-preset.ts @@ -26,6 +26,7 @@ import { ValidatedHtmlInputElement } from "../elements/input-validation"; import { ElementWithUtils } from "../utils/dom"; import { configMetadata } from "../config/metadata"; import { getConfigChanges as getConfigChangesFromConfig } from "../config/utils"; +import { normalizeName } from "../utils/strings"; const state = { presetType: "full" as PresetType, @@ -46,7 +47,12 @@ export function show(action: string, id?: string, name?: string): void { presetNameEl ??= new ValidatedHtmlInputElement( modalEl.qsr("input[type=text]"), { - schema: PresetNameSchema, + isValid: async (name) => { + const parsed = PresetNameSchema.safeParse(normalizeName(name)); + if (parsed.success) return true; + return parsed.error.errors.map((err) => err.message).join(", "); + }, + debounceDelay: 0, }, ); if (action === "add") { @@ -66,7 +72,7 @@ export function show(action: string, id?: string, name?: string): void { modalEl.setAttribute("data-preset-id", id); modalEl.qsr(".popupTitle").setHtml("Edit preset"); modalEl.qsr(".submit").setHtml(`save`); - presetNameEl?.setValue(name.replaceAll(" ", "_")); + presetNameEl?.setValue(name); presetNameEl?.getParent()?.show(); modalEl.qsa("input").show(); @@ -221,7 +227,7 @@ function hide(): void { async function apply(): Promise { const modalEl = modal.getModal(); const action = modalEl.getAttribute("data-action"); - const presetName = modalEl + const propPresetName = modalEl .qsr(".group input[title='presets']") .getValue() as string; const presetId = modalEl.getAttribute("data-preset-id") as string; @@ -248,22 +254,24 @@ async function apply(): Promise { } const addOrEditAction = action === "add" || action === "edit"; - if (addOrEditAction) { - //validate the preset name only in add or edit mode - const noPresetName: boolean = - presetName.replace(/^_+|_+$/g, "").length === 0; //all whitespace names are rejected - if (noPresetName) { - showNoticeNotification("Preset name cannot be empty"); - return; - } + if (addOrEditAction && propPresetName.trim().length === 0) { + showNoticeNotification("Preset name cannot be empty"); + return; + } - if (presetNameEl?.getValidationResult().status === "failed") { - showNoticeNotification("Preset name is not valid"); - return; - } + const cleanedPresetName = normalizeName(propPresetName); + const parsedPresetName = addOrEditAction + ? PresetNameSchema.safeParse(cleanedPresetName) + : null; + + if (parsedPresetName && !parsedPresetName.success) { + showNoticeNotification("Preset name is not valid"); + return; } + const presetName = parsedPresetName?.data ?? ""; + hide(); showLoaderBar(); @@ -291,7 +299,7 @@ async function apply(): Promise { ...(state.presetType === "partial" && { settingGroups: activeSettingGroups, }), - display: presetName.replaceAll("_", " "), + display: presetName.replace(/_/g, " "), _id: response.body.data.presetId, } as SnapshotPreset); } @@ -323,7 +331,7 @@ async function apply(): Promise { showSuccessNotification("Preset updated"); preset.name = presetName; - preset.display = presetName.replaceAll("_", " "); + preset.display = presetName.replace(/_/g, " "); if (updateConfig) { preset.config = configChanges; if (state.presetType === "partial") { diff --git a/frontend/src/ts/modals/edit-tag.ts b/frontend/src/ts/modals/edit-tag.ts index 41bda4879a13..d42507fd7d76 100644 --- a/frontend/src/ts/modals/edit-tag.ts +++ b/frontend/src/ts/modals/edit-tag.ts @@ -6,14 +6,14 @@ import { SimpleModal, TextInput } from "../elements/simple-modal"; import { TagNameSchema } from "@monkeytype/schemas/users"; import { SnapshotUserTag } from "../constants/default-snapshot"; import { IsValidResponse } from "../types/validation"; +import { normalizeName } from "../utils/strings"; function getTagFromSnapshot(tagId: string): SnapshotUserTag | undefined { return DB.getSnapshot()?.tags.find((tag) => tag._id === tagId); } -const cleanTagName = (tagName: string): string => tagName.replaceAll(" ", "_"); const tagNameValidation = async (tagName: string): Promise => { - const validationResult = TagNameSchema.safeParse(cleanTagName(tagName)); + const validationResult = TagNameSchema.safeParse(normalizeName(tagName)); if (validationResult.success) return true; return validationResult.error.errors.map((err) => err.message).join(", "); }; @@ -32,7 +32,7 @@ const actionModals: Record = { ], buttonText: "add", execFn: async (_thisPopup, propTagName) => { - const tagName = cleanTagName(propTagName); + const tagName = TagNameSchema.parse(normalizeName(propTagName)); const response = await Ape.users.createTag({ body: { tagName } }); if (response.status !== 200) { @@ -46,8 +46,8 @@ const actionModals: Record = { } DB.getSnapshot()?.tags?.push({ - display: propTagName, - name: response.body.data.name, + display: tagName.replace(/_/g, " "), + name: tagName, _id: response.body.data._id, personalBests: { time: {}, @@ -77,7 +77,7 @@ const actionModals: Record = { (_thisPopup.inputs[0] as TextInput).initVal = _thisPopup.parameters[0]; }, execFn: async (_thisPopup, propTagName) => { - const tagName = cleanTagName(propTagName); + const tagName = TagNameSchema.parse(normalizeName(propTagName)); const tagId = _thisPopup.parameters[1] as string; const response = await Ape.users.editTag({ @@ -96,7 +96,7 @@ const actionModals: Record = { if (matchingTag !== undefined) { matchingTag.name = tagName; - matchingTag.display = propTagName; + matchingTag.display = tagName.replace(/_/g, " "); } void Settings.update(); diff --git a/frontend/src/ts/modals/new-filter-preset.ts b/frontend/src/ts/modals/new-filter-preset.ts index 3cd94d5b6e23..7a6b6c0efab8 100644 --- a/frontend/src/ts/modals/new-filter-preset.ts +++ b/frontend/src/ts/modals/new-filter-preset.ts @@ -1,6 +1,7 @@ import { ResultFiltersSchema } from "@monkeytype/schemas/users"; import { createFilterPreset } from "../elements/account/result-filters"; import { SimpleModal } from "../elements/simple-modal"; +import { normalizeName } from "../utils/strings"; export function show(): void { newFilterPresetModal.show(undefined, {}); @@ -15,13 +16,23 @@ const newFilterPresetModal = new SimpleModal({ type: "text", initVal: "", validation: { - schema: ResultFiltersSchema.shape.name, + isValid: async (name) => { + const parsed = ResultFiltersSchema.shape.name.safeParse( + normalizeName(name), + ); + if (parsed.success) return true; + return parsed.error.errors.map((err) => err.message).join(", "); + }, + debounceDelay: 0, }, }, ], buttonText: "add", execFn: async (_thisPopup, name) => { - const status = await createFilterPreset(name); + const cleanedName = ResultFiltersSchema.shape.name.parse( + normalizeName(name), + ); + const status = await createFilterPreset(cleanedName); if (status === 1) { return { status: "success", message: "Filter preset created" }; diff --git a/frontend/src/ts/utils/strings.ts b/frontend/src/ts/utils/strings.ts index 6d134f881407..ecbe51568316 100644 --- a/frontend/src/ts/utils/strings.ts +++ b/frontend/src/ts/utils/strings.ts @@ -68,6 +68,14 @@ export function capitalizeFirstLetter(str: string): string { return str.charAt(0).toUpperCase() + str.slice(1); } +/** + * Normalizes free-form names to canonical storage format. + * Trims edge whitespace and collapses all inner whitespace runs to underscores. + */ +export function normalizeName(name: string): string { + return name.trim().replace(/\s+/g, "_"); +} + /** * @param text String to split * @param delimiters Single character delimiters.