From 2cd9ec1db9865ca8c4eabe00c83add2a4a64b791 Mon Sep 17 00:00:00 2001 From: carddev81 Date: Tue, 10 Nov 2026 15:18:10 -0600 Subject: [PATCH] feat(frontend): add instructors inline from Create Class and Edit Class (EN-132) --- .../programs/AddInstructorDialog.tsx | 221 ++++++++++++++++++ frontend/src/lib/validation.ts | 12 + .../src/pages/class-detail/EditClassModal.tsx | 183 +++------------ .../pages/programs/ClassManagementForm.tsx | 149 ++++-------- 4 files changed, 307 insertions(+), 258 deletions(-) create mode 100644 frontend/src/components/programs/AddInstructorDialog.tsx diff --git a/frontend/src/components/programs/AddInstructorDialog.tsx b/frontend/src/components/programs/AddInstructorDialog.tsx new file mode 100644 index 000000000..e35b2328c --- /dev/null +++ b/frontend/src/components/programs/AddInstructorDialog.tsx @@ -0,0 +1,221 @@ +import { useEffect, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import API from '@/api/api'; +import { NewUserResponse, ServerResponseOne, User } from '@/types'; +import { addInstructorSchema, AddInstructorInput } from '@/lib/validation'; +import { toast } from 'sonner'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { DialogFooter } from '@/components/ui/dialog'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from '@/components/ui/form'; +import { FormModal, ResetPasswordModal } from '@/components/shared'; + +// The server's messages for a bad name or username start with the field's +// label, so they can be shown under that field. +const fieldForServerError: [string, keyof AddInstructorInput][] = [ + ['First name', 'name_first'], + ['Last name', 'name_last'], + ['Username', 'username'] +]; + +interface AddInstructorDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + facilityId: number; + onCreated: (user: User) => void; +} + +export function AddInstructorDialog({ + open, + onOpenChange, + facilityId, + onCreated +}: AddInstructorDialogProps) { + const form = useForm({ + resolver: zodResolver(addInstructorSchema), + defaultValues: { name_first: '', name_last: '', username: '' } + }); + const [submitting, setSubmitting] = useState(false); + const [showPasswordModal, setShowPasswordModal] = useState(false); + const [tempPassword, setTempPassword] = useState(''); + const [createdUserName, setCreatedUserName] = useState(''); + const [kratosRegistered, setKratosRegistered] = useState(true); + + useEffect(() => { + if (open) { + form.reset({ name_first: '', name_last: '', username: '' }); + } + }, [open, form]); + + const handleAddInstructor = async (formData: AddInstructorInput) => { + if (submitting) return; + setSubmitting(true); + try { + const response = (await API.post('users', { + user: { + name_first: formData.name_first, + name_last: formData.name_last, + username: formData.username, + email: '', + role: 'facility_admin', + facility_id: facilityId + }, + provider_platforms: [] + })) as ServerResponseOne; + + if (response.success) { + onOpenChange(false); + toast.success( + `Instructor ${formData.name_first} ${formData.name_last} added successfully` + ); + setCreatedUserName( + `${formData.name_first} ${formData.name_last}` + ); + setTempPassword(response.data.temp_password); + setKratosRegistered(response.data.kratos_registered); + setShowPasswordModal(true); + onCreated(response.data.user); + return; + } + const message = response.message ?? 'Failed to create instructor'; + const field = fieldForServerError.find(([label]) => + message.startsWith(label) + )?.[1]; + if (field) { + form.setError(field, { message }); + } else { + form.setError('root', { message }); + } + } finally { + setSubmitting(false); + } + }; + + return ( + <> + +
+ { + e.preventDefault(); + // The class form this opens from is a form too, so + // keep this submit from reaching it. + e.stopPropagation(); + void form.handleSubmit( + (d) => void handleAddInstructor(d) + )(e); + }} + > +
+
+ ( + + First Name + + + + + + )} + /> + ( + + Last Name + + + + + + )} + /> +
+ ( + + Username + + + + + + )} + /> + {form.formState.errors.root && ( +

+ {form.formState.errors.root.message} +

+ )} +
+ + + + +
+ +
+ { + setShowPasswordModal(isOpen); + if (!isOpen) { + setTempPassword(''); + setCreatedUserName(''); + setKratosRegistered(true); + } + }} + name={createdUserName} + subject="administrator" + presetPassword={tempPassword} + resultTitle="New Password" + warning={ + kratosRegistered + ? undefined + : "This instructor's login could not be registered. Use Reset Password on the Admins page before sharing this with them." + } + /> + + ); +} diff --git a/frontend/src/lib/validation.ts b/frontend/src/lib/validation.ts index d95cedd0a..45a582426 100644 --- a/frontend/src/lib/validation.ts +++ b/frontend/src/lib/validation.ts @@ -186,6 +186,18 @@ export const buildAdminEditSchema = (requireFacility: boolean) => export type AdminEditInput = z.infer>; +/** + * Add Instructor, from the class forms. The same fields as Add Admin, without + * role and facility: an instructor is a facility admin at the class's facility. + */ +export const addInstructorSchema = z.object({ + name_first: requiredString('First name'), + name_last: requiredString('Last name'), + username: requiredString('Username') +}); + +export type AddInstructorInput = z.infer; + /* ------------------------------------------------------------------ * * Facility — add / edit * ------------------------------------------------------------------ */ diff --git a/frontend/src/pages/class-detail/EditClassModal.tsx b/frontend/src/pages/class-detail/EditClassModal.tsx index 0b42985ec..8b5e1b1ae 100644 --- a/frontend/src/pages/class-detail/EditClassModal.tsx +++ b/frontend/src/pages/class-detail/EditClassModal.tsx @@ -5,8 +5,9 @@ import { toast } from 'sonner'; import useSWR from 'swr'; import { AlertCircle, X } from 'lucide-react'; import API from '@/api/api'; -import { useAuth } from '@/auth/useAuth'; +import { canSwitchFacility, useAuth } from '@/auth/useAuth'; import { FormModal } from '@/components/shared'; +import { AddInstructorDialog } from '@/components/programs/AddInstructorDialog'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -28,14 +29,7 @@ import { import { editClassSchema, EditClassInput } from '@/lib/validation'; import { SelectedClassStatus } from '@/types/attendance'; import { Instructor } from '@/types/events'; -import { - Cohort, - Room, - RoomConflict, - ServerResponseMany, - ServerResponseOne, - NewUserResponse -} from '@/types'; +import { Cohort, Room, RoomConflict, ServerResponseMany, User } from '@/types'; import { conflictSubjectLabel, formatRoomConflictRange, @@ -231,14 +225,9 @@ export function EditClassModal({ const [conflicts, setConflicts] = useState([]); const [showAddRoom, setShowAddRoom] = useState(false); const [showAddInstructor, setShowAddInstructor] = useState(false); - const [tempPassword, setTempPassword] = useState(''); - const [showPasswordModal, setShowPasswordModal] = useState(false); + // Only department and system admins create admins. + const canAddInstructor = !!user && canSwitchFacility(user); const [newRoomName, setNewRoomName] = useState(''); - const [newInstructor, setNewInstructor] = useState({ - name_first: '', - name_last: '', - username: '' - }); const [scheduleDays, setScheduleDays] = useState( initialSchedule.days ); @@ -339,29 +328,12 @@ export function EditClassModal({ } } - async function handleAddInstructor() { - const { name_first, name_last, username } = newInstructor; - if (!name_first.trim() || !name_last.trim() || !username.trim()) return; - const resp = (await API.post('users', { - user: { - name_first: name_first.trim(), - name_last: name_last.trim(), - username: username.trim(), - role: 'facility_admin' - }, - provider_platforms: [] - })) as ServerResponseOne; - if (resp.success) { - const { user: created, temp_password } = resp.data; - await mutateInstructors(); - if (created?.id) setValue('instructor_id', created.id); - setNewInstructor({ name_first: '', name_last: '', username: '' }); - setShowAddInstructor(false); - setTempPassword(temp_password); - setShowPasswordModal(true); - } else { - toast.error(resp.message || 'Failed to create instructor'); - } + async function handleInstructorCreated(created: User) { + await mutateInstructors(); + setValue('instructor_id', created.id, { + shouldDirty: true, + shouldValidate: true + }); } async function onSubmit(data: EditClassFormData) { @@ -532,12 +504,14 @@ export function EditClassModal({ {inst.name_first} ))} - - + Add Instructor - + {canAddInstructor && ( + + + Add Instructor + + )} @@ -1218,115 +1192,16 @@ export function EditClassModal({ - -
-
-
- - - setNewInstructor((p) => ({ - ...p, - name_first: e.target.value - })) - } - /> -
-
- - - setNewInstructor((p) => ({ - ...p, - name_last: e.target.value - })) - } - /> -
-
-
- - - setNewInstructor((p) => ({ - ...p, - username: e.target.value - })) - } - /> -
-
- - -
-
-
- - { - setShowPasswordModal(open); - if (!open) setTempPassword(''); - }} - title="Instructor Created" - > -
-

- Share this temporary password with the new instructor. - They will be prompted to change it on first login. -

-
-

- Temporary Password -

-

- {tempPassword} -

-
-
- - -
-
-
+ {canAddInstructor && !!cls.facility_id && ( + + void handleInstructorCreated(created) + } + /> + )} ); } diff --git a/frontend/src/pages/programs/ClassManagementForm.tsx b/frontend/src/pages/programs/ClassManagementForm.tsx index f330c2d43..0d0c332c7 100644 --- a/frontend/src/pages/programs/ClassManagementForm.tsx +++ b/frontend/src/pages/programs/ClassManagementForm.tsx @@ -13,7 +13,7 @@ import { programFacilityProps } from '@/lib/events'; import { useFlowTimer } from '@/lib/useFlowTimer'; -import { useAuth } from '@/auth/useAuth'; +import { canSwitchFacility, useAuth } from '@/auth/useAuth'; import { ClassLoaderData, Instructor, @@ -55,6 +55,7 @@ import { SelectValue } from '@/components/ui/select'; import { FormModal } from '@/components/shared'; +import { AddInstructorDialog } from '@/components/programs/AddInstructorDialog'; import { AlertCircle, X } from 'lucide-react'; type ClassFormData = ClassManagementInput; @@ -182,12 +183,9 @@ export function ClassManagementFormInner({ const [showConflicts, setShowConflicts] = useState(false); const [showAddRoom, setShowAddRoom] = useState(false); const [showAddInstructor, setShowAddInstructor] = useState(false); + // Only department and system admins create admins. + const canAddInstructor = !!user && canSwitchFacility(user); const [newRoomName, setNewRoomName] = useState(''); - const [newInstructor, setNewInstructor] = useState({ - name_first: '', - name_last: '', - username: '' - }); const [roomSelectValue, setRoomSelectValue] = useState(''); const [pendingRoomId, setPendingRoomId] = useState(null); const [showCustomRecurrence, setShowCustomRecurrence] = useState(false); @@ -426,34 +424,12 @@ export function ClassManagementFormInner({ setShowAddRoom(false); } - async function handleAddInstructor() { - const { name_first, name_last, username } = newInstructor; - if (!name_first.trim() || !name_last.trim() || !username.trim()) return; - const resp = await API.post< - { user: User; temp_password: string }, - object - >('users', { - user: { - name_first: name_first.trim(), - name_last: name_last.trim(), - username: username.trim(), - role: 'facility_admin' - }, - provider_platforms: [] + function handleInstructorCreated(created: User) { + setInstructors((prev) => [...prev, created]); + setValue('instructor_id', created.id, { + shouldDirty: true, + shouldValidate: true }); - if (resp.success) { - const newUser = (resp.data as { user: User; temp_password: string }) - .user; - setInstructors((prev) => [ - ...prev, - newUser as unknown as Instructor - ]); - toast.success('Instructor created'); - } else { - toast.error(resp.message || 'Failed to create instructor'); - } - setNewInstructor({ name_first: '', name_last: '', username: '' }); - setShowAddInstructor(false); } async function onSubmit(data: ClassFormData) { @@ -726,11 +702,20 @@ export function ClassManagementFormInner({ ) : '' } - onValueChange={(v) => + onValueChange={(v) => { + // Radix sends '' when the value is set before its + // option renders, as for a new instructor. + if (!v) return; + if (v === '__add__') { + setShowAddInstructor( + true + ); + return; + } field.onChange( Number(v) - ) - } + ); + }} > ) )} + {canAddInstructor && ( + + + Add Instructor + + )} @@ -1505,6 +1498,9 @@ export function ClassManagementFormInner({ : '' } onValueChange={(v) => { + // Radix sends '' when the value is set before its + // option renders, as for a new instructor. + if (!v) return; if (v === '__add__') { setShowAddInstructor( true @@ -1542,12 +1538,14 @@ export function ClassManagementFormInner({ ) )} - - + Add Instructor - + {canAddInstructor && ( + + + Add Instructor + + )} @@ -1936,70 +1934,13 @@ export function ClassManagementFormInner({ )} - {!embedded && ( - -
-
-
- - - setNewInstructor((p) => ({ - ...p, - name_first: e.target.value - })) - } - /> -
-
- - - setNewInstructor((p) => ({ - ...p, - name_last: e.target.value - })) - } - /> -
-
-
- - - setNewInstructor((p) => ({ - ...p, - username: e.target.value - })) - } - /> -
-
- - -
-
-
+ facilityId={resolvedFacilityId} + onCreated={handleInstructorCreated} + /> )} );