From ead8a566d03bdd3abe578ef1773b19c5a2a069e8 Mon Sep 17 00:00:00 2001 From: counterweight Date: Fri, 19 Dec 2025 10:52:47 +0100 Subject: [PATCH] small validation fixes --- frontend/app/profile/page.test.tsx | 113 +++++------------- frontend/app/profile/page.tsx | 46 +++++-- frontend/e2e/profile.spec.ts | 23 +++- frontend/test-results/.last-run.json | 7 +- .../error-context.md | 57 +++++++++ .../error-context.md | 57 +++++++++ 6 files changed, 201 insertions(+), 102 deletions(-) create mode 100644 frontend/test-results/profile-Profile---Validati-940e7-x-validation-error-and-save/error-context.md create mode 100644 frontend/test-results/profile-Profile---Validati-e4785-am-handle-that-is-too-short/error-context.md diff --git a/frontend/app/profile/page.test.tsx b/frontend/app/profile/page.test.tsx index dd8843f..691faab 100644 --- a/frontend/app/profile/page.test.tsx +++ b/frontend/app/profile/page.test.tsx @@ -305,84 +305,7 @@ describe("ProfilePage - Form Behavior", () => { }); }); - test("shows inline error for invalid telegram handle", async () => { - vi.spyOn(global, "fetch").mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ - contact_email: null, - telegram: null, - signal: null, - nostr_npub: null, - }), - } as Response); - - render(); - - await waitFor(() => { - expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined(); - }); - - const telegramInput = document.getElementById("telegram") as HTMLInputElement; - fireEvent.change(telegramInput, { target: { value: "noatsign" } }); - - await waitFor(() => { - expect(screen.getByText(/must start with @/i)).toBeDefined(); - }); - }); - - test("shows inline error for invalid npub", async () => { - vi.spyOn(global, "fetch").mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ - contact_email: null, - telegram: null, - signal: null, - nostr_npub: null, - }), - } as Response); - - render(); - - await waitFor(() => { - expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined(); - }); - - const npubInput = document.getElementById("nostr_npub") as HTMLInputElement; - fireEvent.change(npubInput, { target: { value: "invalidnpub" } }); - - await waitFor(() => { - expect(screen.getByText(/must start with 'npub1'/i)).toBeDefined(); - }); - }); - - test("submit button is disabled when form has validation errors", async () => { - vi.spyOn(global, "fetch").mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ - contact_email: null, - telegram: null, - signal: null, - nostr_npub: null, - }), - } as Response); - - render(); - - await waitFor(() => { - expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined(); - }); - - // Enter invalid telegram (no @) - const telegramInput = document.getElementById("telegram") as HTMLInputElement; - fireEvent.change(telegramInput, { target: { value: "noatsign" } }); - - await waitFor(() => { - const submitButton = screen.getByRole("button", { name: /save changes/i }); - expect(submitButton).toHaveProperty("disabled", true); - }); - }); - - test("clears error when field becomes valid", async () => { + test("auto-prepends @ to telegram when user starts with letter", async () => { vi.spyOn(global, "fetch").mockResolvedValue({ ok: true, json: () => Promise.resolve({ @@ -401,17 +324,35 @@ describe("ProfilePage - Form Behavior", () => { const telegramInput = document.getElementById("telegram") as HTMLInputElement; - // First enter invalid value - fireEvent.change(telegramInput, { target: { value: "noat" } }); + // Type a letter without @ - should auto-prepend @ + fireEvent.change(telegramInput, { target: { value: "myhandle" } }); + + expect(telegramInput.value).toBe("@myhandle"); + }); + + test("does not auto-prepend @ if user types @ first", async () => { + vi.spyOn(global, "fetch").mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ + contact_email: null, + telegram: null, + signal: null, + nostr_npub: null, + }), + } as Response); + + render(); + await waitFor(() => { - expect(screen.getByText(/must start with @/i)).toBeDefined(); + expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined(); }); - // Then fix it - fireEvent.change(telegramInput, { target: { value: "@validhandle" } }); - await waitFor(() => { - expect(screen.queryByText(/must start with @/i)).toBeNull(); - }); + const telegramInput = document.getElementById("telegram") as HTMLInputElement; + + // User types @ first - no auto-prepend + fireEvent.change(telegramInput, { target: { value: "@myhandle" } }); + + expect(telegramInput.value).toBe("@myhandle"); }); }); diff --git a/frontend/app/profile/page.tsx b/frontend/app/profile/page.tsx index 31bb859..b2ee3d0 100644 --- a/frontend/app/profile/page.tsx +++ b/frontend/app/profile/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState, useCallback } from "react"; +import { useEffect, useState, useCallback, useRef } from "react"; import { useRouter } from "next/navigation"; import { bech32 } from "bech32"; import { useAuth } from "../auth-context"; @@ -124,6 +124,7 @@ export default function ProfilePage() { const [isLoadingProfile, setIsLoadingProfile] = useState(true); const [isSubmitting, setIsSubmitting] = useState(false); const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null); + const validationTimeoutRef = useRef(null); const isRegularUser = hasRole("regular"); @@ -166,7 +167,8 @@ export default function ProfilePage() { } else { setToast({ message: "Failed to load profile", type: "error" }); } - } catch { + } catch (err) { + console.error("Profile load error:", err); setToast({ message: "Network error. Please try again.", type: "error" }); } finally { setIsLoadingProfile(false); @@ -187,14 +189,39 @@ export default function ProfilePage() { } }, [toast]); + // Cleanup validation timeout on unmount + useEffect(() => { + return () => { + if (validationTimeoutRef.current) { + clearTimeout(validationTimeoutRef.current); + } + }; + }, []); + const handleInputChange = (field: keyof FormData) => (e: React.ChangeEvent) => { - const value = e.target.value; + let value = e.target.value; + + // For telegram: auto-prepend @ if user starts with a valid letter + if (field === "telegram" && value && !value.startsWith("@")) { + // Check if first char is a valid telegram handle start (letter) + if (/^[a-zA-Z]/.test(value)) { + value = "@" + value; + } + } + setFormData((prev) => ({ ...prev, [field]: value })); - // Validate on change and clear error if valid - const newFormData = { ...formData, [field]: value }; - const newErrors = validateForm(newFormData); - setErrors(newErrors); + // Clear any pending validation timeout + if (validationTimeoutRef.current) { + clearTimeout(validationTimeoutRef.current); + } + + // Debounce validation - wait 500ms after user stops typing + validationTimeoutRef.current = setTimeout(() => { + const newFormData = { ...formData, [field]: value }; + const newErrors = validateForm(newFormData); + setErrors(newErrors); + }, 500); }; const handleSubmit = async (e: React.FormEvent) => { @@ -239,7 +266,8 @@ export default function ProfilePage() { } else { setToast({ message: "Failed to save profile", type: "error" }); } - } catch { + } catch (err) { + console.error("Profile save error:", err); setToast({ message: "Network error. Please try again.", type: "error" }); } finally { setIsSubmitting(false); @@ -502,7 +530,7 @@ const pageStyles: Record = { cursor: "not-allowed", }, inputError: { - borderColor: "rgba(239, 68, 68, 0.5)", + border: "1px solid rgba(239, 68, 68, 0.5)", boxShadow: "0 0 0 2px rgba(239, 68, 68, 0.1)", }, hint: { diff --git a/frontend/e2e/profile.spec.ts b/frontend/e2e/profile.spec.ts index 35499ab..dd4736c 100644 --- a/frontend/e2e/profile.spec.ts +++ b/frontend/e2e/profile.spec.ts @@ -227,14 +227,27 @@ test.describe("Profile - Validation", () => { await clearProfileData(page); }); - test("shows error for invalid telegram handle (missing @)", async ({ page }) => { + test("auto-prepends @ for telegram when starting with letter", async ({ page }) => { await page.goto("/profile"); - // Enter invalid telegram (no @) - await page.fill("#telegram", "noatsign"); + // Type a letter without @ - should auto-prepend @ + await page.fill("#telegram", "testhandle"); - // Should show error - await expect(page.getByText(/must start with @/i)).toBeVisible(); + // Should show @testhandle in the input + await expect(page.locator("#telegram")).toHaveValue("@testhandle"); + }); + + test("shows error for telegram handle that is too short", async ({ page }) => { + await page.goto("/profile"); + + // Enter telegram with @ but too short (needs 5+ chars) + await page.fill("#telegram", "@ab"); + + // Wait for debounced validation + await page.waitForTimeout(600); + + // Should show error about length + await expect(page.getByText(/at least 5 characters/i)).toBeVisible(); // Save button should be disabled const saveButton = page.getByRole("button", { name: /save changes/i }); diff --git a/frontend/test-results/.last-run.json b/frontend/test-results/.last-run.json index cbcc1fb..56cd5cc 100644 --- a/frontend/test-results/.last-run.json +++ b/frontend/test-results/.last-run.json @@ -1,4 +1,7 @@ { - "status": "passed", - "failedTests": [] + "status": "failed", + "failedTests": [ + "e8b79b4ee550a37632f1-b6f4d12ec6021e7a3bc8", + "e8b79b4ee550a37632f1-600f6ae7070fb14ef7f9" + ] } \ No newline at end of file diff --git a/frontend/test-results/profile-Profile---Validati-940e7-x-validation-error-and-save/error-context.md b/frontend/test-results/profile-Profile---Validati-940e7-x-validation-error-and-save/error-context.md new file mode 100644 index 0000000..b455bdb --- /dev/null +++ b/frontend/test-results/profile-Profile---Validati-940e7-x-validation-error-and-save/error-context.md @@ -0,0 +1,57 @@ +# Page snapshot + +```yaml +- generic [ref=e1]: + - main [ref=e2]: + - generic [ref=e3]: + - generic [ref=e4]: + - link "Counter" [ref=e5] [cursor=pointer]: + - /url: / + - generic [ref=e6]: • + - link "Sum" [ref=e7] [cursor=pointer]: + - /url: /sum + - generic [ref=e8]: • + - generic [ref=e9]: My Profile + - generic [ref=e10]: + - generic [ref=e11]: user@example.com + - button "Sign out" [ref=e12] [cursor=pointer] + - generic [ref=e14]: + - generic [ref=e15]: + - heading "My Profile" [level=1] [ref=e16] + - paragraph [ref=e17]: Manage your contact information + - generic [ref=e18]: + - generic [ref=e19]: + - generic [ref=e20]: + - text: Login Email + - generic [ref=e21]: Read only + - textbox [disabled] [ref=e22]: user@example.com + - generic [ref=e23]: This is your login email and cannot be changed here. + - paragraph [ref=e25]: Contact Details + - paragraph [ref=e26]: These are for communication purposes only — they won't affect your login. + - generic [ref=e27]: + - generic [ref=e28]: Contact Email + - textbox "Contact Email" [ref=e29]: + - /placeholder: alternate@example.com + - generic [ref=e30]: + - generic [ref=e31]: Telegram + - textbox "Telegram" [active] [ref=e32]: + - /placeholder: "@username" + - text: "@noat" + - generic [ref=e33]: + - generic [ref=e34]: Signal + - textbox "Signal" [ref=e35]: + - /placeholder: username.01 + - generic [ref=e36]: + - generic [ref=e37]: Nostr (npub) + - textbox "Nostr (npub)" [ref=e38]: + - /placeholder: npub1... + - button "Save Changes" [ref=e39] [cursor=pointer] + - status [ref=e40]: + - generic [ref=e41]: + - img [ref=e43] + - generic [ref=e45]: + - text: Static route + - button "Hide static indicator" [ref=e46] [cursor=pointer]: + - img [ref=e47] + - alert [ref=e50] +``` \ No newline at end of file diff --git a/frontend/test-results/profile-Profile---Validati-e4785-am-handle-that-is-too-short/error-context.md b/frontend/test-results/profile-Profile---Validati-e4785-am-handle-that-is-too-short/error-context.md new file mode 100644 index 0000000..595df88 --- /dev/null +++ b/frontend/test-results/profile-Profile---Validati-e4785-am-handle-that-is-too-short/error-context.md @@ -0,0 +1,57 @@ +# Page snapshot + +```yaml +- generic [ref=e1]: + - main [ref=e2]: + - generic [ref=e3]: + - generic [ref=e4]: + - link "Counter" [ref=e5] [cursor=pointer]: + - /url: / + - generic [ref=e6]: • + - link "Sum" [ref=e7] [cursor=pointer]: + - /url: /sum + - generic [ref=e8]: • + - generic [ref=e9]: My Profile + - generic [ref=e10]: + - generic [ref=e11]: user@example.com + - button "Sign out" [ref=e12] [cursor=pointer] + - generic [ref=e14]: + - generic [ref=e15]: + - heading "My Profile" [level=1] [ref=e16] + - paragraph [ref=e17]: Manage your contact information + - generic [ref=e18]: + - generic [ref=e19]: + - generic [ref=e20]: + - text: Login Email + - generic [ref=e21]: Read only + - textbox [disabled] [ref=e22]: user@example.com + - generic [ref=e23]: This is your login email and cannot be changed here. + - paragraph [ref=e25]: Contact Details + - paragraph [ref=e26]: These are for communication purposes only — they won't affect your login. + - generic [ref=e27]: + - generic [ref=e28]: Contact Email + - textbox "Contact Email" [ref=e29]: + - /placeholder: alternate@example.com + - generic [ref=e30]: + - generic [ref=e31]: Telegram + - textbox "Telegram" [active] [ref=e32]: + - /placeholder: "@username" + - text: "@ab" + - generic [ref=e33]: + - generic [ref=e34]: Signal + - textbox "Signal" [ref=e35]: + - /placeholder: username.01 + - generic [ref=e36]: + - generic [ref=e37]: Nostr (npub) + - textbox "Nostr (npub)" [ref=e38]: + - /placeholder: npub1... + - button "Save Changes" [ref=e39] [cursor=pointer] + - status [ref=e40]: + - generic [ref=e41]: + - img [ref=e43] + - generic [ref=e45]: + - text: Static route + - button "Hide static indicator" [ref=e46] [cursor=pointer]: + - img [ref=e47] + - alert [ref=e50] +``` \ No newline at end of file