From 1d1b445f9d666c7353b4e6b1710d506be4870b92 Mon Sep 17 00:00:00 2001 From: byseif21 Date: Sun, 22 Mar 2026 18:21:18 +0200 Subject: [PATCH 1/8] impr: enable spaces in preset names and unify naming logic --- .../src/ts/elements/account/result-filters.ts | 1 - frontend/src/ts/modals/edit-preset.ts | 16 +++++++++------- frontend/src/ts/modals/edit-tag.ts | 7 +++---- frontend/src/ts/modals/new-filter-preset.ts | 3 ++- packages/schemas/src/presets.ts | 5 +++-- packages/schemas/src/users.ts | 10 ++++++---- 6 files changed, 23 insertions(+), 19 deletions(-) diff --git a/frontend/src/ts/elements/account/result-filters.ts b/frontend/src/ts/elements/account/result-filters.ts index 852c91ef8b63..d2af7dbcb0d5 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 65764829ec9d..5cbf03a7baac 100644 --- a/frontend/src/ts/modals/edit-preset.ts +++ b/frontend/src/ts/modals/edit-preset.ts @@ -67,7 +67,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(); @@ -272,9 +272,10 @@ async function apply(): Promise { if (action === "add") { const configChanges = getConfigChanges(); const activeSettingGroups = getActiveSettingGroupsFromState(); + const cleanedName = PresetNameSchema.parse(presetName); const response = await Ape.presets.add({ body: { - name: presetName, + name: cleanedName, config: configChanges, ...(state.presetType === "partial" && { settingGroups: activeSettingGroups, @@ -287,12 +288,12 @@ async function apply(): Promise { } else { showSuccessNotification("Preset added", { durationMs: 2000 }); snapshotPresets.push({ - name: presetName, + name: cleanedName, config: configChanges, ...(state.presetType === "partial" && { settingGroups: activeSettingGroups, }), - display: presetName.replaceAll("_", " "), + display: presetName, _id: response.body.data.presetId, } as SnapshotPreset); } @@ -307,10 +308,11 @@ async function apply(): Promise { const configChanges = getConfigChanges(); const activeSettingGroups: ConfigGroupName[] | null = state.presetType === "partial" ? getActiveSettingGroupsFromState() : null; + const cleanedName = PresetNameSchema.parse(presetName); const response = await Ape.presets.save({ body: { _id: presetId, - name: presetName, + name: cleanedName, ...(updateConfig && { config: configChanges, settingGroups: activeSettingGroups, @@ -323,8 +325,8 @@ async function apply(): Promise { } else { showSuccessNotification("Preset updated"); - preset.name = presetName; - preset.display = presetName.replaceAll("_", " "); + preset.name = cleanedName; + preset.display = presetName; 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 83f466a7727d..4266870965b1 100644 --- a/frontend/src/ts/modals/edit-tag.ts +++ b/frontend/src/ts/modals/edit-tag.ts @@ -11,9 +11,8 @@ 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(tagName); if (validationResult.success) return true; return validationResult.error.errors.map((err) => err.message).join(", "); }; @@ -32,7 +31,7 @@ const actionModals: Record = { ], buttonText: "add", execFn: async (_thisPopup, propTagName) => { - const tagName = cleanTagName(propTagName); + const tagName = TagNameSchema.parse(propTagName); const response = await Ape.users.createTag({ body: { tagName } }); if (response.status !== 200) { @@ -77,7 +76,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(propTagName); const tagId = _thisPopup.parameters[1] as string; const response = await Ape.users.editTag({ diff --git a/frontend/src/ts/modals/new-filter-preset.ts b/frontend/src/ts/modals/new-filter-preset.ts index aea866b6369d..29a7ab13c774 100644 --- a/frontend/src/ts/modals/new-filter-preset.ts +++ b/frontend/src/ts/modals/new-filter-preset.ts @@ -21,7 +21,8 @@ const newFilterPresetModal = new SimpleModal({ ], buttonText: "add", execFn: async (_thisPopup, name) => { - const status = await createFilterPreset(name); + const cleanedName = ResultFiltersSchema.shape.name.parse(name); + const status = await createFilterPreset(cleanedName); if (status === 1) { return { status: "success", message: "Filter preset created" }; diff --git a/packages/schemas/src/presets.ts b/packages/schemas/src/presets.ts index 5faeb2a68224..2a7d90c0c3a7 100644 --- a/packages/schemas/src/presets.ts +++ b/packages/schemas/src/presets.ts @@ -8,8 +8,9 @@ import { export const PresetNameSchema = z .string() - .regex(/^[0-9a-zA-Z_-]+$/) - .max(16); + .max(16) + .transform((val) => val.replace(/ /g, "_")) + .pipe(z.string().regex(/^[0-9a-zA-Z_-]+$/)); export type PresetName = z.infer; export const PresetTypeSchema = z.enum(["full", "partial"]); diff --git a/packages/schemas/src/users.ts b/packages/schemas/src/users.ts index e592c1bea026..a9c40e132a13 100644 --- a/packages/schemas/src/users.ts +++ b/packages/schemas/src/users.ts @@ -20,8 +20,9 @@ export const ResultFiltersSchema = z.object({ _id: IdSchema, name: z .string() - .regex(/^[0-9a-zA-Z_.-]+$/) - .max(16), + .max(16) + .transform((val) => val.replace(/ /g, "_")) + .pipe(z.string().regex(/^[0-9a-zA-Z_.-]+$/)), pb: z .object({ no: z.boolean(), @@ -299,8 +300,9 @@ export type ResultFiltersGroupItem = export const TagNameSchema = z .string() - .regex(/^[0-9a-zA-Z_.-]+$/) - .max(16); + .max(16) + .transform((val) => val.replace(/ /g, "_")) + .pipe(z.string().regex(/^[0-9a-zA-Z_.-]+$/)); export type TagName = z.infer; export const TypingStatsSchema = z.object({ From f0a5ae8c69d4018754e29d1074eab2ee6300c707 Mon Sep 17 00:00:00 2001 From: byseif21 Date: Sun, 22 Mar 2026 19:53:03 +0200 Subject: [PATCH 2/8] avoid temp _ till refresh if the name had _ by user --- frontend/src/ts/modals/edit-preset.ts | 4 ++-- frontend/src/ts/modals/edit-tag.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/ts/modals/edit-preset.ts b/frontend/src/ts/modals/edit-preset.ts index feab3816e328..67de8063bee6 100644 --- a/frontend/src/ts/modals/edit-preset.ts +++ b/frontend/src/ts/modals/edit-preset.ts @@ -292,7 +292,7 @@ async function apply(): Promise { ...(state.presetType === "partial" && { settingGroups: activeSettingGroups, }), - display: presetName, + display: cleanedName.replace(/_/g, " "), _id: response.body.data.presetId, } as SnapshotPreset); } @@ -325,7 +325,7 @@ async function apply(): Promise { showSuccessNotification("Preset updated"); preset.name = cleanedName; - preset.display = presetName; + preset.display = cleanedName.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 368ebf0242fa..9c9f85358175 100644 --- a/frontend/src/ts/modals/edit-tag.ts +++ b/frontend/src/ts/modals/edit-tag.ts @@ -45,7 +45,7 @@ const actionModals: Record = { } DB.getSnapshot()?.tags?.push({ - display: propTagName, + display: tagName.replace(/_/g, " "), name: response.body.data.name, _id: response.body.data._id, personalBests: { @@ -95,7 +95,7 @@ const actionModals: Record = { if (matchingTag !== undefined) { matchingTag.name = tagName; - matchingTag.display = propTagName; + matchingTag.display = tagName.replace(/_/g, " "); } void Settings.update(); From cdf70c239ec71cc2f0e74b862563bc4069292ae2 Mon Sep 17 00:00:00 2001 From: byseif21 Date: Sun, 22 Mar 2026 19:55:23 +0200 Subject: [PATCH 3/8] rename to keep the naming --- frontend/src/ts/modals/edit-preset.ts | 20 ++++++++++---------- frontend/src/ts/modals/edit-tag.ts | 2 +- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/frontend/src/ts/modals/edit-preset.ts b/frontend/src/ts/modals/edit-preset.ts index 67de8063bee6..e53952ff3e93 100644 --- a/frontend/src/ts/modals/edit-preset.ts +++ b/frontend/src/ts/modals/edit-preset.ts @@ -221,7 +221,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; @@ -252,7 +252,7 @@ async function apply(): Promise { //validate the preset name only in add or edit mode const noPresetName: boolean = - presetName.replace(/^_+|_+$/g, "").length === 0; //all whitespace names are rejected + propPresetName.replace(/^_+|_+$/g, "").length === 0; //all whitespace names are rejected if (noPresetName) { showNoticeNotification("Preset name cannot be empty"); return; @@ -271,10 +271,10 @@ async function apply(): Promise { if (action === "add") { const configChanges = getConfigChanges(); const activeSettingGroups = getActiveSettingGroupsFromState(); - const cleanedName = PresetNameSchema.parse(presetName); + const presetName = PresetNameSchema.parse(propPresetName); const response = await Ape.presets.add({ body: { - name: cleanedName, + name: presetName, config: configChanges, ...(state.presetType === "partial" && { settingGroups: activeSettingGroups, @@ -287,12 +287,12 @@ async function apply(): Promise { } else { showSuccessNotification("Preset added", { durationMs: 2000 }); snapshotPresets.push({ - name: cleanedName, + name: presetName, config: configChanges, ...(state.presetType === "partial" && { settingGroups: activeSettingGroups, }), - display: cleanedName.replace(/_/g, " "), + display: presetName.replace(/_/g, " "), _id: response.body.data.presetId, } as SnapshotPreset); } @@ -307,11 +307,11 @@ async function apply(): Promise { const configChanges = getConfigChanges(); const activeSettingGroups: ConfigGroupName[] | null = state.presetType === "partial" ? getActiveSettingGroupsFromState() : null; - const cleanedName = PresetNameSchema.parse(presetName); + const presetName = PresetNameSchema.parse(propPresetName); const response = await Ape.presets.save({ body: { _id: presetId, - name: cleanedName, + name: presetName, ...(updateConfig && { config: configChanges, settingGroups: activeSettingGroups, @@ -324,8 +324,8 @@ async function apply(): Promise { } else { showSuccessNotification("Preset updated"); - preset.name = cleanedName; - preset.display = cleanedName.replace(/_/g, " "); + preset.name = presetName; + 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 9c9f85358175..45436ad950f9 100644 --- a/frontend/src/ts/modals/edit-tag.ts +++ b/frontend/src/ts/modals/edit-tag.ts @@ -46,7 +46,7 @@ const actionModals: Record = { DB.getSnapshot()?.tags?.push({ display: tagName.replace(/_/g, " "), - name: response.body.data.name, + name: tagName, _id: response.body.data._id, personalBests: { time: {}, From 7eae756a709e60aac38ff905b9231a3708475ef6 Mon Sep 17 00:00:00 2001 From: byseif21 Date: Mon, 23 Mar 2026 14:38:27 +0200 Subject: [PATCH 4/8] move length validation after whitespace transform trim() to clean bad useless whitespaces --- packages/schemas/src/presets.ts | 10 +++++++--- packages/schemas/src/users.ts | 20 ++++++++++++++------ 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/packages/schemas/src/presets.ts b/packages/schemas/src/presets.ts index 2a7d90c0c3a7..c296646cd84a 100644 --- a/packages/schemas/src/presets.ts +++ b/packages/schemas/src/presets.ts @@ -8,9 +8,13 @@ import { export const PresetNameSchema = z .string() - .max(16) - .transform((val) => val.replace(/ /g, "_")) - .pipe(z.string().regex(/^[0-9a-zA-Z_-]+$/)); + .transform((val) => val.trim().replace(/\s+/g, "_")) + .pipe( + z + .string() + .max(16) + .regex(/^[0-9a-zA-Z_-]+$/), + ); export type PresetName = z.infer; export const PresetTypeSchema = z.enum(["full", "partial"]); diff --git a/packages/schemas/src/users.ts b/packages/schemas/src/users.ts index a9c40e132a13..cb8e2a67ff7e 100644 --- a/packages/schemas/src/users.ts +++ b/packages/schemas/src/users.ts @@ -20,9 +20,13 @@ export const ResultFiltersSchema = z.object({ _id: IdSchema, name: z .string() - .max(16) - .transform((val) => val.replace(/ /g, "_")) - .pipe(z.string().regex(/^[0-9a-zA-Z_.-]+$/)), + .transform((val) => val.trim().replace(/\s+/g, "_")) + .pipe( + z + .string() + .max(16) + .regex(/^[0-9a-zA-Z_.-]+$/), + ), pb: z .object({ no: z.boolean(), @@ -300,9 +304,13 @@ export type ResultFiltersGroupItem = export const TagNameSchema = z .string() - .max(16) - .transform((val) => val.replace(/ /g, "_")) - .pipe(z.string().regex(/^[0-9a-zA-Z_.-]+$/)); + .transform((val) => val.trim().replace(/\s+/g, "_")) + .pipe( + z + .string() + .max(16) + .regex(/^[0-9a-zA-Z_.-]+$/), + ); export type TagName = z.infer; export const TypingStatsSchema = z.object({ From 2b41d54c0f92cecd8aba13c5a0f0a919965ba9c1 Mon Sep 17 00:00:00 2001 From: byseif21 Date: Mon, 23 Mar 2026 14:51:15 +0200 Subject: [PATCH 5/8] cleanup & use safeParse with presets --- frontend/src/ts/modals/edit-preset.ts | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/frontend/src/ts/modals/edit-preset.ts b/frontend/src/ts/modals/edit-preset.ts index e53952ff3e93..84104cb3601f 100644 --- a/frontend/src/ts/modals/edit-preset.ts +++ b/frontend/src/ts/modals/edit-preset.ts @@ -249,16 +249,8 @@ 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 = - propPresetName.replace(/^_+|_+$/g, "").length === 0; //all whitespace names are rejected - if (noPresetName) { - showNoticeNotification("Preset name cannot be empty"); - return; - } - - if (presetNameEl?.getValidationResult().status === "failed") { + const parsedPresetName = PresetNameSchema.safeParse(propPresetName); + if (!parsedPresetName.success) { showNoticeNotification("Preset name is not valid"); return; } @@ -271,7 +263,7 @@ async function apply(): Promise { if (action === "add") { const configChanges = getConfigChanges(); const activeSettingGroups = getActiveSettingGroupsFromState(); - const presetName = PresetNameSchema.parse(propPresetName); + const presetName = PresetNameSchema.safeParse(propPresetName).data ?? ""; const response = await Ape.presets.add({ body: { name: presetName, @@ -307,7 +299,7 @@ async function apply(): Promise { const configChanges = getConfigChanges(); const activeSettingGroups: ConfigGroupName[] | null = state.presetType === "partial" ? getActiveSettingGroupsFromState() : null; - const presetName = PresetNameSchema.parse(propPresetName); + const presetName = PresetNameSchema.safeParse(propPresetName).data ?? ""; const response = await Ape.presets.save({ body: { _id: presetId, From 317277d6299fe5780adb707dd264d26334961c9a Mon Sep 17 00:00:00 2001 From: byseif21 Date: Mon, 23 Mar 2026 15:31:46 +0200 Subject: [PATCH 6/8] consistency --- frontend/src/ts/db.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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; From 56fe731f82c8fe32aa578b1051ba57e08c91af81 Mon Sep 17 00:00:00 2001 From: byseif21 Date: Mon, 23 Mar 2026 18:23:06 +0200 Subject: [PATCH 7/8] review & readd empty notify --- frontend/src/ts/modals/edit-preset.ts | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/frontend/src/ts/modals/edit-preset.ts b/frontend/src/ts/modals/edit-preset.ts index 84104cb3601f..ac7de95182c8 100644 --- a/frontend/src/ts/modals/edit-preset.ts +++ b/frontend/src/ts/modals/edit-preset.ts @@ -248,14 +248,23 @@ async function apply(): Promise { } const addOrEditAction = action === "add" || action === "edit"; - if (addOrEditAction) { - const parsedPresetName = PresetNameSchema.safeParse(propPresetName); - if (!parsedPresetName.success) { - showNoticeNotification("Preset name is not valid"); - return; - } + + if (addOrEditAction && propPresetName.trim().length === 0) { + showNoticeNotification("Preset name cannot be empty"); + return; } + const parsedPresetName = addOrEditAction + ? PresetNameSchema.safeParse(propPresetName) + : null; + + if (parsedPresetName && !parsedPresetName.success) { + showNoticeNotification("Preset name is not valid"); + return; + } + + const presetName = parsedPresetName?.data ?? ""; + hide(); showLoaderBar(); @@ -263,7 +272,6 @@ async function apply(): Promise { if (action === "add") { const configChanges = getConfigChanges(); const activeSettingGroups = getActiveSettingGroupsFromState(); - const presetName = PresetNameSchema.safeParse(propPresetName).data ?? ""; const response = await Ape.presets.add({ body: { name: presetName, @@ -299,7 +307,6 @@ async function apply(): Promise { const configChanges = getConfigChanges(); const activeSettingGroups: ConfigGroupName[] | null = state.presetType === "partial" ? getActiveSettingGroupsFromState() : null; - const presetName = PresetNameSchema.safeParse(propPresetName).data ?? ""; const response = await Ape.presets.save({ body: { _id: presetId, From 10ddbd2585d805dfad0b695d6c7ee2848940c575 Mon Sep 17 00:00:00 2001 From: byseif21 Date: Mon, 23 Mar 2026 19:16:45 +0200 Subject: [PATCH 8/8] change approach keep name normalization in frontend, restore strict schema validation --- frontend/src/ts/modals/edit-preset.ts | 11 +++++++++-- frontend/src/ts/modals/edit-tag.ts | 7 ++++--- frontend/src/ts/modals/new-filter-preset.ts | 14 ++++++++++++-- frontend/src/ts/utils/strings.ts | 8 ++++++++ packages/schemas/src/presets.ts | 9 ++------- packages/schemas/src/users.ts | 18 ++++-------------- 6 files changed, 39 insertions(+), 28 deletions(-) diff --git a/frontend/src/ts/modals/edit-preset.ts b/frontend/src/ts/modals/edit-preset.ts index ac7de95182c8..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") { @@ -254,8 +260,9 @@ async function apply(): Promise { return; } + const cleanedPresetName = normalizeName(propPresetName); const parsedPresetName = addOrEditAction - ? PresetNameSchema.safeParse(propPresetName) + ? PresetNameSchema.safeParse(cleanedPresetName) : null; if (parsedPresetName && !parsedPresetName.success) { diff --git a/frontend/src/ts/modals/edit-tag.ts b/frontend/src/ts/modals/edit-tag.ts index 45436ad950f9..d42507fd7d76 100644 --- a/frontend/src/ts/modals/edit-tag.ts +++ b/frontend/src/ts/modals/edit-tag.ts @@ -6,13 +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 tagNameValidation = async (tagName: string): Promise => { - const validationResult = TagNameSchema.safeParse(tagName); + const validationResult = TagNameSchema.safeParse(normalizeName(tagName)); if (validationResult.success) return true; return validationResult.error.errors.map((err) => err.message).join(", "); }; @@ -31,7 +32,7 @@ const actionModals: Record = { ], buttonText: "add", execFn: async (_thisPopup, propTagName) => { - const tagName = TagNameSchema.parse(propTagName); + const tagName = TagNameSchema.parse(normalizeName(propTagName)); const response = await Ape.users.createTag({ body: { tagName } }); if (response.status !== 200) { @@ -76,7 +77,7 @@ const actionModals: Record = { (_thisPopup.inputs[0] as TextInput).initVal = _thisPopup.parameters[0]; }, execFn: async (_thisPopup, propTagName) => { - const tagName = TagNameSchema.parse(propTagName); + const tagName = TagNameSchema.parse(normalizeName(propTagName)); const tagId = _thisPopup.parameters[1] as string; const response = await Ape.users.editTag({ diff --git a/frontend/src/ts/modals/new-filter-preset.ts b/frontend/src/ts/modals/new-filter-preset.ts index 050d97938759..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,22 @@ 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 cleanedName = ResultFiltersSchema.shape.name.parse(name); + const cleanedName = ResultFiltersSchema.shape.name.parse( + normalizeName(name), + ); const status = await createFilterPreset(cleanedName); if (status === 1) { 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. diff --git a/packages/schemas/src/presets.ts b/packages/schemas/src/presets.ts index c296646cd84a..5faeb2a68224 100644 --- a/packages/schemas/src/presets.ts +++ b/packages/schemas/src/presets.ts @@ -8,13 +8,8 @@ import { export const PresetNameSchema = z .string() - .transform((val) => val.trim().replace(/\s+/g, "_")) - .pipe( - z - .string() - .max(16) - .regex(/^[0-9a-zA-Z_-]+$/), - ); + .regex(/^[0-9a-zA-Z_-]+$/) + .max(16); export type PresetName = z.infer; export const PresetTypeSchema = z.enum(["full", "partial"]); diff --git a/packages/schemas/src/users.ts b/packages/schemas/src/users.ts index cb8e2a67ff7e..e592c1bea026 100644 --- a/packages/schemas/src/users.ts +++ b/packages/schemas/src/users.ts @@ -20,13 +20,8 @@ export const ResultFiltersSchema = z.object({ _id: IdSchema, name: z .string() - .transform((val) => val.trim().replace(/\s+/g, "_")) - .pipe( - z - .string() - .max(16) - .regex(/^[0-9a-zA-Z_.-]+$/), - ), + .regex(/^[0-9a-zA-Z_.-]+$/) + .max(16), pb: z .object({ no: z.boolean(), @@ -304,13 +299,8 @@ export type ResultFiltersGroupItem = export const TagNameSchema = z .string() - .transform((val) => val.trim().replace(/\s+/g, "_")) - .pipe( - z - .string() - .max(16) - .regex(/^[0-9a-zA-Z_.-]+$/), - ); + .regex(/^[0-9a-zA-Z_.-]+$/) + .max(16); export type TagName = z.infer; export const TypingStatsSchema = z.object({