small validation fixes

This commit is contained in:
counterweight 2025-12-19 10:52:47 +01:00
parent bbc5625b2d
commit ead8a566d0
Signed by: counterweight
GPG key ID: 883EDBAA726BD96C
6 changed files with 201 additions and 102 deletions

View file

@ -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(<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 () => {
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(<ProfilePage />);
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");
});
});

View file

@ -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<NodeJS.Timeout | null>(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<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 }));
// 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<string, React.CSSProperties> = {
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: {