From 77679922a8c42b5f7269e47364699dbdfd201c99 Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 18 Apr 2026 15:35:29 -0400 Subject: [PATCH] Phase 1d: react-hook-form + zod on Login and NewTicket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Both forms use useForm with zodResolver against shared schemas (loginSchema, createTicketSchema) - Field-level errors rendered inline under inputs - isSubmitting drives button disabled state - NewTicket: severity registered with valueAsNumber; CTISelect wrapped in nested Controllers (one per categoryId/typeId/itemId) since it controls three form fields as a single compound input - Admin forms stay on useState for now — they get redesigned with shadcn dialogs in Phase 3, RHF migration lands with that Co-Authored-By: Claude Opus 4.7 --- client/src/pages/Login.tsx | 56 +++++++------- client/src/pages/NewTicket.tsx | 132 ++++++++++++++++++--------------- 2 files changed, 100 insertions(+), 88 deletions(-) diff --git a/client/src/pages/Login.tsx b/client/src/pages/Login.tsx index 15e7e1d..0751cad 100644 --- a/client/src/pages/Login.tsx +++ b/client/src/pages/Login.tsx @@ -1,29 +1,37 @@ import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { loginSchema, type LoginInput } from '../../../shared/schemas/auth'; import { useAuth } from '../contexts/AuthContext'; export default function Login() { const { login } = useAuth(); const navigate = useNavigate(); - const [username, setUsername] = useState(''); - const [password, setPassword] = useState(''); const [error, setError] = useState(''); - const [loading, setLoading] = useState(false); - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(loginSchema), + defaultValues: { username: '', password: '' }, + }); + + const onSubmit = async (data: LoginInput) => { setError(''); - setLoading(true); try { - await login(username, password); + await login(data.username, data.password); navigate('/'); } catch { setError('Invalid username or password'); - } finally { - setLoading(false); } }; + const inputClass = + 'w-full bg-gray-800 border border-gray-700 text-gray-100 placeholder-gray-500 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent'; + return (
@@ -33,8 +41,9 @@ export default function Login() {
{error && (
@@ -44,33 +53,26 @@ export default function Login() {
- setUsername(e.target.value)} - required - autoFocus - className="w-full bg-gray-800 border border-gray-700 text-gray-100 placeholder-gray-500 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" - /> + + {errors.username && ( +

{errors.username.message}

+ )}
- setPassword(e.target.value)} - required - className="w-full bg-gray-800 border border-gray-700 text-gray-100 placeholder-gray-500 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" - /> + + {errors.password && ( +

{errors.password.message}

+ )}
diff --git a/client/src/pages/NewTicket.tsx b/client/src/pages/NewTicket.tsx index ae882c8..2a30587 100644 --- a/client/src/pages/NewTicket.tsx +++ b/client/src/pages/NewTicket.tsx @@ -1,9 +1,11 @@ -import { useState, useEffect } from 'react'; +import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import api from '../api/client'; +import { useForm, Controller } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { createTicketSchema, type CreateTicketInput } from '../../../shared/schemas/ticket'; import Modal from '../components/Modal'; import CTISelect from '../components/CTISelect'; -import { User } from '../types'; +import { useUsers, useCreateTicket } from '../api/queries'; interface NewTicketModalProps { onClose: () => void; @@ -11,64 +13,49 @@ interface NewTicketModalProps { export default function NewTicketModal({ onClose }: NewTicketModalProps) { const navigate = useNavigate(); - const [users, setUsers] = useState([]); + const { data: users = [] } = useUsers(); + const createTicket = useCreateTicket(); const [error, setError] = useState(''); - const [submitting, setSubmitting] = useState(false); - const [form, setForm] = useState({ - title: '', - overview: '', - severity: 3, - assigneeId: '', - categoryId: '', - typeId: '', - itemId: '', + const { + register, + handleSubmit, + control, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(createTicketSchema), + defaultValues: { + title: '', + overview: '', + severity: 3, + categoryId: '', + typeId: '', + itemId: '', + assigneeId: undefined, + }, }); - useEffect(() => { - api.get('/users').then((r) => setUsers(r.data)); - }, []); - - const handleCTI = (cti: { categoryId: string; typeId: string; itemId: string }) => { - setForm((f) => ({ ...f, ...cti })); - }; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - if (!form.categoryId || !form.typeId || !form.itemId) { - setError('Please select a Category, Type, and Item'); - return; - } + const onSubmit = async (data: CreateTicketInput) => { setError(''); - setSubmitting(true); try { - const payload: Record = { - title: form.title, - overview: form.overview, - severity: form.severity, - categoryId: form.categoryId, - typeId: form.typeId, - itemId: form.itemId, - }; - if (form.assigneeId) payload.assigneeId = form.assigneeId; - - const res = await api.post('/tickets', payload); + const payload: Record = { ...data }; + if (!data.assigneeId) delete payload.assigneeId; + const created = await createTicket.mutateAsync(payload); onClose(); - navigate(`/${res.data.displayId}`); + navigate(`/${created.displayId}`); } catch { setError('Failed to create ticket'); - } finally { - setSubmitting(false); } }; const inputClass = 'w-full bg-gray-800 border border-gray-700 text-gray-100 placeholder-gray-500 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent'; const labelClass = 'block text-sm font-medium text-gray-300 mb-1'; + const errorClass = 'mt-1 text-xs text-red-400'; return ( -
+ {error && (
{error} @@ -79,34 +66,31 @@ export default function NewTicketModal({ onClose }: NewTicketModalProps) { setForm((f) => ({ ...f, title: e.target.value }))} - required className={inputClass} placeholder="Brief description of the issue" autoFocus + {...register('title')} /> + {errors.title &&

{errors.title.message}

}