small validation fixes
This commit is contained in:
parent
bbc5625b2d
commit
ead8a566d0
6 changed files with 201 additions and 102 deletions
|
|
@ -305,84 +305,7 @@ describe("ProfilePage - Form Behavior", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("shows inline error for invalid telegram handle", async () => {
|
test("auto-prepends @ to telegram when user starts with letter", async () => {
|
||||||
vi.spyOn(global, "fetch").mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
json: () => Promise.resolve({
|
|
||||||
contact_email: null,
|
|
||||||
telegram: null,
|
|
||||||
signal: null,
|
|
||||||
nostr_npub: null,
|
|
||||||
}),
|
|
||||||
} as Response);
|
|
||||||
|
|
||||||
render(<ProfilePage />);
|
|
||||||
|
|
||||||
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(<ProfilePage />);
|
|
||||||
|
|
||||||
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(<ProfilePage />);
|
|
||||||
|
|
||||||
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 () => {
|
|
||||||
vi.spyOn(global, "fetch").mockResolvedValue({
|
vi.spyOn(global, "fetch").mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: () => Promise.resolve({
|
json: () => Promise.resolve({
|
||||||
|
|
@ -401,17 +324,35 @@ describe("ProfilePage - Form Behavior", () => {
|
||||||
|
|
||||||
const telegramInput = document.getElementById("telegram") as HTMLInputElement;
|
const telegramInput = document.getElementById("telegram") as HTMLInputElement;
|
||||||
|
|
||||||
// First enter invalid value
|
// Type a letter without @ - should auto-prepend @
|
||||||
fireEvent.change(telegramInput, { target: { value: "noat" } });
|
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(<ProfilePage />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText(/must start with @/i)).toBeDefined();
|
expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Then fix it
|
const telegramInput = document.getElementById("telegram") as HTMLInputElement;
|
||||||
fireEvent.change(telegramInput, { target: { value: "@validhandle" } });
|
|
||||||
await waitFor(() => {
|
// User types @ first - no auto-prepend
|
||||||
expect(screen.queryByText(/must start with @/i)).toBeNull();
|
fireEvent.change(telegramInput, { target: { value: "@myhandle" } });
|
||||||
});
|
|
||||||
|
expect(telegramInput.value).toBe("@myhandle");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from "react";
|
import { useEffect, useState, useCallback, useRef } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { bech32 } from "bech32";
|
import { bech32 } from "bech32";
|
||||||
import { useAuth } from "../auth-context";
|
import { useAuth } from "../auth-context";
|
||||||
|
|
@ -124,6 +124,7 @@ export default function ProfilePage() {
|
||||||
const [isLoadingProfile, setIsLoadingProfile] = useState(true);
|
const [isLoadingProfile, setIsLoadingProfile] = useState(true);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
|
const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
|
||||||
|
const validationTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
const isRegularUser = hasRole("regular");
|
const isRegularUser = hasRole("regular");
|
||||||
|
|
||||||
|
|
@ -166,7 +167,8 @@ export default function ProfilePage() {
|
||||||
} else {
|
} else {
|
||||||
setToast({ message: "Failed to load profile", type: "error" });
|
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" });
|
setToast({ message: "Network error. Please try again.", type: "error" });
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoadingProfile(false);
|
setIsLoadingProfile(false);
|
||||||
|
|
@ -187,14 +189,39 @@ export default function ProfilePage() {
|
||||||
}
|
}
|
||||||
}, [toast]);
|
}, [toast]);
|
||||||
|
|
||||||
|
// Cleanup validation timeout on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (validationTimeoutRef.current) {
|
||||||
|
clearTimeout(validationTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleInputChange = (field: keyof FormData) => (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleInputChange = (field: keyof FormData) => (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
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 }));
|
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||||
|
|
||||||
// Validate on change and clear error if valid
|
// Clear any pending validation timeout
|
||||||
const newFormData = { ...formData, [field]: value };
|
if (validationTimeoutRef.current) {
|
||||||
const newErrors = validateForm(newFormData);
|
clearTimeout(validationTimeoutRef.current);
|
||||||
setErrors(newErrors);
|
}
|
||||||
|
|
||||||
|
// 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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
|
@ -239,7 +266,8 @@ export default function ProfilePage() {
|
||||||
} else {
|
} else {
|
||||||
setToast({ message: "Failed to save profile", type: "error" });
|
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" });
|
setToast({ message: "Network error. Please try again.", type: "error" });
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
|
|
@ -502,7 +530,7 @@ const pageStyles: Record<string, React.CSSProperties> = {
|
||||||
cursor: "not-allowed",
|
cursor: "not-allowed",
|
||||||
},
|
},
|
||||||
inputError: {
|
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)",
|
boxShadow: "0 0 0 2px rgba(239, 68, 68, 0.1)",
|
||||||
},
|
},
|
||||||
hint: {
|
hint: {
|
||||||
|
|
|
||||||
|
|
@ -227,14 +227,27 @@ test.describe("Profile - Validation", () => {
|
||||||
await clearProfileData(page);
|
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");
|
await page.goto("/profile");
|
||||||
|
|
||||||
// Enter invalid telegram (no @)
|
// Type a letter without @ - should auto-prepend @
|
||||||
await page.fill("#telegram", "noatsign");
|
await page.fill("#telegram", "testhandle");
|
||||||
|
|
||||||
// Should show error
|
// Should show @testhandle in the input
|
||||||
await expect(page.getByText(/must start with @/i)).toBeVisible();
|
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
|
// Save button should be disabled
|
||||||
const saveButton = page.getByRole("button", { name: /save changes/i });
|
const saveButton = page.getByRole("button", { name: /save changes/i });
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
{
|
{
|
||||||
"status": "passed",
|
"status": "failed",
|
||||||
"failedTests": []
|
"failedTests": [
|
||||||
|
"e8b79b4ee550a37632f1-b6f4d12ec6021e7a3bc8",
|
||||||
|
"e8b79b4ee550a37632f1-600f6ae7070fb14ef7f9"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -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]
|
||||||
|
```
|
||||||
|
|
@ -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]
|
||||||
|
```
|
||||||
Loading…
Add table
Add a link
Reference in a new issue