diff --git a/src/components/ContactEdit/ContactEditPanel.tsx b/src/components/ContactEdit/ContactEditPanel.tsx new file mode 100644 index 00000000..ca27999d --- /dev/null +++ b/src/components/ContactEdit/ContactEditPanel.tsx @@ -0,0 +1,1272 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { Label } from '@/components/ui/label' +import { + useUpdate, + useNotification, + useInvalidate, + useCustomMutation, +} from '@refinedev/core' +import { Loader2, PlusIcon, Trash2Icon } from 'lucide-react' +import { captureEvent } from '@/analytics/posthog' +import { Button, buttonVariants } from '@/components/ui/button' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Input } from '@/components/ui/input' +import { Skeleton } from '@/components/ui/skeleton' +import { + EditPanel, + EditPanelField, + EditPanelSection, +} from '@/components/editing' +import { useLexicon } from '@/hooks' +import { getContactDisplayName } from '@/utils/contactDisplayName' +import type { IContact } from '@/interfaces/ocotillo' + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface ContactEditPanelProps { + contactId: string | number + contact: IContact | undefined + isLoading?: boolean + onClose: () => void + onSaved?: () => void +} + +interface ContactDetailsDraft { + name: string + organization: string | null + role: string + contact_type: string +} + +const ORG_NONE = '__none__' + +interface EmailDraft { + draftId: string + id?: number + email: string + email_type: string +} + +interface PhoneDraft { + draftId: string + id?: number + phone_number: string + phone_type: string +} + +interface AddressDraft { + draftId: string + id?: number + address_line_1: string + address_line_2: string + city: string + state: string + postal_code: string + country: string + address_type: string +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +let _draftIdCounter = 0 +function generateDraftId() { + return `draft-${++_draftIdCounter}` +} + +function initContactDraft(contact: IContact | undefined): ContactDetailsDraft { + return { + name: contact?.name ?? '', + organization: contact?.organization ?? null, + role: contact?.role ?? '', + contact_type: contact?.contact_type ?? '', + } +} + +function initEmailDrafts(contact: IContact | undefined): EmailDraft[] { + return (contact?.emails ?? []).map((e) => ({ + draftId: generateDraftId(), + id: e.id, + email: e.email, + email_type: e.email_type, + })) +} + +function initPhoneDrafts(contact: IContact | undefined): PhoneDraft[] { + return (contact?.phones ?? []).map((p) => ({ + draftId: generateDraftId(), + id: p.id, + phone_number: e164ToDisplay(p.phone_number), + phone_type: p.phone_type, + })) +} + +function initAddressDrafts(contact: IContact | undefined): AddressDraft[] { + return (contact?.addresses ?? []).map((a) => ({ + draftId: generateDraftId(), + id: a.id, + address_line_1: a.address_line_1 ?? '', + address_line_2: a.address_line_2 ?? '', + city: a.city ?? '', + state: a.state ?? '', + postal_code: a.postal_code ?? '', + country: a.country ?? '', + address_type: a.address_type ?? '', + })) +} + +function contactDraftsEqual( + a: ContactDetailsDraft, + b: ContactDetailsDraft +): boolean { + return ( + a.name === b.name && + a.organization === b.organization && + a.role === b.role && + a.contact_type === b.contact_type + ) +} + +function isEmailModified(draft: EmailDraft, initials: EmailDraft[]): boolean { + if (!draft.id) return false + const orig = initials.find((i) => i.id === draft.id) + if (!orig) return false + return draft.email !== orig.email || draft.email_type !== orig.email_type +} + +function isPhoneModified(draft: PhoneDraft, initials: PhoneDraft[]): boolean { + if (!draft.id) return false + const orig = initials.find((i) => i.id === draft.id) + if (!orig) return false + return draft.phone_number !== orig.phone_number || draft.phone_type !== orig.phone_type +} + +// ─── US states ─────────────────────────────────────────────────────────────── + +const US_STATES = [ + { value: 'AL', label: 'AL – Alabama' }, + { value: 'AK', label: 'AK – Alaska' }, + { value: 'AZ', label: 'AZ – Arizona' }, + { value: 'AR', label: 'AR – Arkansas' }, + { value: 'CA', label: 'CA – California' }, + { value: 'CO', label: 'CO – Colorado' }, + { value: 'CT', label: 'CT – Connecticut' }, + { value: 'DE', label: 'DE – Delaware' }, + { value: 'FL', label: 'FL – Florida' }, + { value: 'GA', label: 'GA – Georgia' }, + { value: 'HI', label: 'HI – Hawaii' }, + { value: 'ID', label: 'ID – Idaho' }, + { value: 'IL', label: 'IL – Illinois' }, + { value: 'IN', label: 'IN – Indiana' }, + { value: 'IA', label: 'IA – Iowa' }, + { value: 'KS', label: 'KS – Kansas' }, + { value: 'KY', label: 'KY – Kentucky' }, + { value: 'LA', label: 'LA – Louisiana' }, + { value: 'ME', label: 'ME – Maine' }, + { value: 'MD', label: 'MD – Maryland' }, + { value: 'MA', label: 'MA – Massachusetts' }, + { value: 'MI', label: 'MI – Michigan' }, + { value: 'MN', label: 'MN – Minnesota' }, + { value: 'MS', label: 'MS – Mississippi' }, + { value: 'MO', label: 'MO – Missouri' }, + { value: 'MT', label: 'MT – Montana' }, + { value: 'NE', label: 'NE – Nebraska' }, + { value: 'NV', label: 'NV – Nevada' }, + { value: 'NH', label: 'NH – New Hampshire' }, + { value: 'NJ', label: 'NJ – New Jersey' }, + { value: 'NM', label: 'NM – New Mexico' }, + { value: 'NY', label: 'NY – New York' }, + { value: 'NC', label: 'NC – North Carolina' }, + { value: 'ND', label: 'ND – North Dakota' }, + { value: 'OH', label: 'OH – Ohio' }, + { value: 'OK', label: 'OK – Oklahoma' }, + { value: 'OR', label: 'OR – Oregon' }, + { value: 'PA', label: 'PA – Pennsylvania' }, + { value: 'RI', label: 'RI – Rhode Island' }, + { value: 'SC', label: 'SC – South Carolina' }, + { value: 'SD', label: 'SD – South Dakota' }, + { value: 'TN', label: 'TN – Tennessee' }, + { value: 'TX', label: 'TX – Texas' }, + { value: 'UT', label: 'UT – Utah' }, + { value: 'VT', label: 'VT – Vermont' }, + { value: 'VA', label: 'VA – Virginia' }, + { value: 'WA', label: 'WA – Washington' }, + { value: 'WV', label: 'WV – West Virginia' }, + { value: 'WI', label: 'WI – Wisconsin' }, + { value: 'WY', label: 'WY – Wyoming' }, + { value: 'DC', label: 'DC – Washington D.C.' }, +] + +// ─── Phone / email helpers ──────────────────────────────────────────────────── + +function formatPhoneDigits(digits: string): string { + const d = digits.replace(/\D/g, '').slice(0, 10) + if (d.length === 0) return '' + if (d.length <= 3) return `(${d}` + if (d.length <= 6) return `(${d.slice(0, 3)}) ${d.slice(3)}` + return `(${d.slice(0, 3)}) ${d.slice(3, 6)}-${d.slice(6)}` +} + +function e164ToDisplay(e164: string | undefined | null): string { + if (!e164) return '' + const digits = e164.replace(/\D/g, '') + const local = + digits.length === 11 && digits.startsWith('1') ? digits.slice(1) : digits + return formatPhoneDigits(local) +} + +function displayToE164(display: string): string { + const digits = display.replace(/\D/g, '') + return digits.length === 10 ? `+1${digits}` : display +} + +function isValidEmail(email: string): boolean { + if (!email.trim()) return true + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim()) +} + +function isValidPhone(display: string): boolean { + if (!display.trim()) return true + return display.replace(/\D/g, '').length === 10 +} + +function isAddressModified( + draft: AddressDraft, + initials: AddressDraft[] +): boolean { + if (!draft.id) return false + const orig = initials.find((i) => i.id === draft.id) + if (!orig) return false + return ( + draft.address_line_1 !== orig.address_line_1 || + draft.address_line_2 !== orig.address_line_2 || + draft.city !== orig.city || + draft.state !== orig.state || + draft.postal_code !== orig.postal_code || + draft.country !== orig.country || + draft.address_type !== orig.address_type + ) +} + +// ─── Sub-section row components ─────────────────────────────────────────────── + +function EmailRow({ + email, + onChange, + onDelete, + disabled, + typeOptions, +}: { + email: EmailDraft + onChange: (updated: EmailDraft) => void + onDelete: () => void + disabled: boolean + typeOptions: { value: string; label: string }[] +}) { + const [touched, setTouched] = useState(false) + const invalid = !isValidEmail(email.email) + const showError = touched && invalid + const errorId = `email-error-${email.draftId}` + + return ( +
+
+
+ + onChange({ ...email, email: e.target.value })} + onBlur={() => setTouched(true)} + disabled={disabled} + className={`h-8 text-sm ${showError ? 'border-destructive focus-visible:ring-destructive' : ''}`} + placeholder="name@example.com" + aria-invalid={showError} + aria-describedby={errorId} + /> +
+
+ + +
+ +
+

+ Enter a valid email address. +

+
+ ) +} + +function PhoneRow({ + phone, + onChange, + onDelete, + disabled, + typeOptions, +}: { + phone: PhoneDraft + onChange: (updated: PhoneDraft) => void + onDelete: () => void + disabled: boolean + typeOptions: { value: string; label: string }[] +}) { + const [touched, setTouched] = useState(false) + const invalid = phone.phone_number.trim() !== '' && !isValidPhone(phone.phone_number) + const showError = touched && invalid + const errorId = `phone-error-${phone.draftId}` + + return ( +
+
+
+ +
+ +1 +
+
+
+ + { + const formatted = formatPhoneDigits(e.target.value) + onChange({ ...phone, phone_number: formatted }) + }} + onBlur={() => setTouched(true)} + disabled={disabled} + className={`h-8 text-sm ${showError ? 'border-destructive focus-visible:ring-destructive' : ''}`} + placeholder="(505) 555-0100" + aria-invalid={showError} + aria-describedby={errorId} + /> +
+
+ + +
+ +
+

+ Enter a 10-digit US phone number. +

+
+ ) +} + +function AddressBlock({ + address, + onChange, + onDelete, + disabled, + typeOptions, +}: { + address: AddressDraft + onChange: (updated: AddressDraft) => void + onDelete: () => void + disabled: boolean + typeOptions: { value: string; label: string }[] +}) { + return ( +
+
+ + +
+ onChange({ ...address, address_line_1: e.target.value })} + disabled={disabled} + className="h-8 text-sm" + placeholder="Address line 1" + aria-label="Address line 1" + /> + onChange({ ...address, address_line_2: e.target.value })} + disabled={disabled} + className="h-8 text-sm" + placeholder="Address line 2 (optional)" + aria-label="Address line 2" + /> +
+ onChange({ ...address, city: e.target.value })} + disabled={disabled} + className="h-8 text-sm" + placeholder="City" + aria-label="City" + /> + + onChange({ ...address, postal_code: e.target.value })} + disabled={disabled} + className="h-8 text-sm" + placeholder="Postal code" + aria-label="Postal code" + /> + onChange({ ...address, country: e.target.value })} + disabled={disabled} + className="h-8 text-sm" + placeholder="Country" + aria-label="Country" + /> +
+
+ ) +} + +// ─── Main component ─────────────────────────────────────────────────────────── + +export function ContactEditPanel({ + contactId, + contact, + isLoading = false, + onClose, + onSaved, +}: ContactEditPanelProps) { + const { open: notify } = useNotification() + const invalidate = useInvalidate() + const { mutateAsync: update } = useUpdate() + const { mutateAsync: mutate } = useCustomMutation() + + const [isSaving, setIsSaving] = useState(false) + const [discardDialogOpen, setDiscardDialogOpen] = useState(false) + const wasLoadingRef = useRef(true) + + // ── Contact details state ────────────────────────────────────────────────── + const [draft, setDraft] = useState(() => + initContactDraft(contact) + ) + const [initial, setInitial] = useState(() => + initContactDraft(contact) + ) + + // ── Sub-resource draft state ─────────────────────────────────────────────── + const [draftEmails, setDraftEmails] = useState(() => + initEmailDrafts(contact) + ) + const [initialEmails, setInitialEmails] = useState(() => + initEmailDrafts(contact) + ) + const [deletedEmailIds, setDeletedEmailIds] = useState>(new Set()) + + const [draftPhones, setDraftPhones] = useState(() => + initPhoneDrafts(contact) + ) + const [initialPhones, setInitialPhones] = useState(() => + initPhoneDrafts(contact) + ) + const [deletedPhoneIds, setDeletedPhoneIds] = useState>(new Set()) + + const [draftAddresses, setDraftAddresses] = useState(() => + initAddressDrafts(contact) + ) + const [initialAddresses, setInitialAddresses] = useState(() => + initAddressDrafts(contact) + ) + const [deletedAddressIds, setDeletedAddressIds] = useState>( + new Set() + ) + + // ── Lexicon options ──────────────────────────────────────────────────────── + const { options: roleOptions, isLoading: roleLoading } = useLexicon({ + category: 'role', + }) + const { options: contactTypeOptions, isLoading: contactTypeLoading } = + useLexicon({ category: 'contact_type' }) + const { options: emailTypeOptions, isLoading: emailTypeLoading } = useLexicon({ + category: 'email_type', + }) + const { options: phoneTypeOptions, isLoading: phoneTypeLoading } = useLexicon({ + category: 'phone_type', + }) + const { options: addressTypeOptions, isLoading: addressTypeLoading } = + useLexicon({ category: 'address_type' }) + const { options: organizationOptions, isLoading: organizationLoading } = + useLexicon({ category: 'organization' }) + + const isOptionsLoading = + roleLoading || + contactTypeLoading || + emailTypeLoading || + phoneTypeLoading || + addressTypeLoading || + organizationLoading + + const panelTitle = contact + ? `Edit: ${getContactDisplayName(contact)}` + : 'Edit' + + // ── Sync state when contact loads ───────────────────────────────────────── + useEffect(() => { + captureEvent('edit_panel_opened', { + resource: 'contact', + contact_id: contactId, + }) + }, [contactId]) + + useEffect(() => { + wasLoadingRef.current = true + }, [contactId]) + + useEffect(() => { + if (isLoading) { + wasLoadingRef.current = true + return + } + if (!wasLoadingRef.current) return + + const syncedContact = initContactDraft(contact) + setDraft(syncedContact) + setInitial(syncedContact) + + const syncedEmails = initEmailDrafts(contact) + setDraftEmails(syncedEmails) + setInitialEmails(syncedEmails) + setDeletedEmailIds(new Set()) + + const syncedPhones = initPhoneDrafts(contact) + setDraftPhones(syncedPhones) + setInitialPhones(syncedPhones) + setDeletedPhoneIds(new Set()) + + const syncedAddresses = initAddressDrafts(contact) + setDraftAddresses(syncedAddresses) + setInitialAddresses(syncedAddresses) + setDeletedAddressIds(new Set()) + + wasLoadingRef.current = false + }, [contact, isLoading, contactId]) + + // ── isDirty ─────────────────────────────────────────────────────────────── + const hasValidationErrors = useMemo(() => { + if (draftEmails.some((e) => !isValidEmail(e.email))) return true + if (draftPhones.some((p) => p.phone_number.trim() !== '' && !isValidPhone(p.phone_number))) return true + return false + }, [draftEmails, draftPhones]) + + const isDirty = useMemo(() => { + if (!contactDraftsEqual(draft, initial)) return true + if (deletedEmailIds.size > 0) return true + if (deletedPhoneIds.size > 0) return true + if (deletedAddressIds.size > 0) return true + if (draftEmails.some((e) => (!e.id && e.email.trim() !== '') || (e.id != null && isEmailModified(e, initialEmails)))) + return true + if (draftPhones.some((p) => (!p.id && p.phone_number.trim() !== '') || (p.id != null && isPhoneModified(p, initialPhones)))) + return true + if (draftAddresses.some((a) => (!a.id && a.address_line_1.trim() !== '') || (a.id != null && isAddressModified(a, initialAddresses)))) + return true + return false + }, [ + draft, + initial, + draftEmails, + initialEmails, + deletedEmailIds, + draftPhones, + initialPhones, + deletedPhoneIds, + draftAddresses, + initialAddresses, + deletedAddressIds, + ]) + + // ── Handlers ───────────────────────────────────────────────────────────── + + const handleDeleteEmail = (email: EmailDraft) => { + setDraftEmails((prev) => prev.filter((e) => e.draftId !== email.draftId)) + if (email.id != null) { + setDeletedEmailIds((prev) => new Set([...prev, email.id!])) + } + } + + const handleDeletePhone = (phone: PhoneDraft) => { + setDraftPhones((prev) => prev.filter((p) => p.draftId !== phone.draftId)) + if (phone.id != null) { + setDeletedPhoneIds((prev) => new Set([...prev, phone.id!])) + } + } + + const handleDeleteAddress = (address: AddressDraft) => { + setDraftAddresses((prev) => + prev.filter((a) => a.draftId !== address.draftId) + ) + if (address.id != null) { + setDeletedAddressIds((prev) => new Set([...prev, address.id!])) + } + } + + const handleSave = async () => { + if (!isDirty || isSaving) return + + const invalidEmails = draftEmails.filter((e) => !isValidEmail(e.email)) + if (invalidEmails.length > 0) { + notify?.({ + type: 'error', + message: 'Fix the invalid email addresses before saving.', + }) + return + } + + const invalidPhones = draftPhones.filter( + (p) => p.phone_number.trim() !== '' && !isValidPhone(p.phone_number) + ) + if (invalidPhones.length > 0) { + notify?.({ + type: 'error', + message: 'Phone numbers must be 10 digits.', + }) + return + } + + setIsSaving(true) + + try { + const ops: Promise[] = [] + const changedSections: string[] = [] + + // ── Contact details ────────────────────────────────────────────────── + if (!contactDraftsEqual(draft, initial)) { + const changes: Record = {} + if (draft.name !== initial.name) changes.name = draft.name || undefined + if (draft.organization !== initial.organization) + changes.organization = draft.organization + if (draft.role !== initial.role) changes.role = draft.role || undefined + if (draft.contact_type !== initial.contact_type) + changes.contact_type = draft.contact_type || undefined + + ops.push( + update({ + resource: 'contact', + dataProviderName: 'ocotillo', + id: contactId, + values: changes, + successNotification: false, + }) + ) + changedSections.push('contact_details') + } + + // ── Emails ─────────────────────────────────────────────────────────── + if ( + deletedEmailIds.size > 0 || + draftEmails.some((e) => !e.id || isEmailModified(e, initialEmails)) + ) { + changedSections.push('emails') + } + + for (const id of deletedEmailIds) { + ops.push( + mutate({ + url: `contact/email/${id}`, + method: 'delete', + values: {}, + dataProviderName: 'ocotillo', + }) + ) + } + + for (const email of draftEmails) { + if (email.id == null && email.email.trim()) { + ops.push( + mutate({ + url: 'contact/email', + method: 'post', + values: { + contact_id: Number(contactId), + email: email.email, + email_type: email.email_type, + }, + dataProviderName: 'ocotillo', + }) + ) + } else if (email.id != null && isEmailModified(email, initialEmails)) { + ops.push( + mutate({ + url: `contact/email/${email.id}`, + method: 'patch', + values: { email: email.email, email_type: email.email_type }, + dataProviderName: 'ocotillo', + }) + ) + } + } + + // ── Phones ─────────────────────────────────────────────────────────── + if ( + deletedPhoneIds.size > 0 || + draftPhones.some((p) => !p.id || isPhoneModified(p, initialPhones)) + ) { + changedSections.push('phones') + } + + for (const id of deletedPhoneIds) { + ops.push( + mutate({ + url: `contact/phone/${id}`, + method: 'delete', + values: {}, + dataProviderName: 'ocotillo', + }) + ) + } + + for (const phone of draftPhones) { + if (phone.id == null && phone.phone_number.trim()) { + ops.push( + mutate({ + url: 'contact/phone', + method: 'post', + values: { + contact_id: Number(contactId), + phone_number: displayToE164(phone.phone_number), + phone_type: phone.phone_type, + }, + dataProviderName: 'ocotillo', + }) + ) + } else if ( + phone.id != null && + isPhoneModified(phone, initialPhones) + ) { + ops.push( + mutate({ + url: `contact/phone/${phone.id}`, + method: 'patch', + values: { + phone_number: displayToE164(phone.phone_number), + phone_type: phone.phone_type, + }, + dataProviderName: 'ocotillo', + }) + ) + } + } + + // ── Addresses ──────────────────────────────────────────────────────── + if ( + deletedAddressIds.size > 0 || + draftAddresses.some((a) => !a.id || isAddressModified(a, initialAddresses)) + ) { + changedSections.push('addresses') + } + + for (const id of deletedAddressIds) { + ops.push( + mutate({ + url: `contact/address/${id}`, + method: 'delete', + values: {}, + dataProviderName: 'ocotillo', + }) + ) + } + + for (const address of draftAddresses) { + if (address.id == null && address.address_line_1.trim()) { + ops.push( + mutate({ + url: 'contact/address', + method: 'post', + values: { + contact_id: Number(contactId), + address_line_1: address.address_line_1, + address_line_2: address.address_line_2 || undefined, + city: address.city || undefined, + state: address.state || undefined, + postal_code: address.postal_code || undefined, + country: address.country || undefined, + address_type: address.address_type, + }, + dataProviderName: 'ocotillo', + }) + ) + } else if ( + address.id != null && + isAddressModified(address, initialAddresses) + ) { + ops.push( + mutate({ + url: `contact/address/${address.id}`, + method: 'patch', + values: { + address_line_1: address.address_line_1, + address_line_2: address.address_line_2 || undefined, + city: address.city || undefined, + state: address.state || undefined, + postal_code: address.postal_code || undefined, + country: address.country || undefined, + address_type: address.address_type, + }, + dataProviderName: 'ocotillo', + }) + ) + } + } + + await Promise.all(ops) + await invalidate({ + resource: 'contact', + dataProviderName: 'ocotillo', + id: contactId, + invalidates: ['detail', 'list'], + }) + + captureEvent('edit_saved', { + resource: 'contact', + contact_id: contactId, + fields_changed: changedSections, + }) + onSaved?.() + onClose() + } catch { + notify?.({ + type: 'error', + message: 'Could not save changes. Please try again.', + }) + } finally { + setIsSaving(false) + } + } + + const handleRequestClose = () => { + if (isSaving) return + if (isDirty) { + setDiscardDialogOpen(true) + return + } + onClose() + } + + const handleDiscardChanges = () => { + captureEvent('edit_abandoned', { + resource: 'contact', + contact_id: contactId, + had_changes: isDirty, + }) + onClose() + } + + // ── Render ──────────────────────────────────────────────────────────────── + + return ( + <> + + + + + } + > + {/* Contact Details */} + + {isLoading ? ( + <> +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + ) : ( + <> + + {isOptionsLoading ? ( + + ) : ( + + )} + + + {isOptionsLoading ? ( + + ) : ( + + )} + + + + setDraft((prev) => ({ ...prev, name: e.target.value })) + } + disabled={isSaving} + className="h-8 text-sm" + placeholder="Contact name" + /> + + + {isOptionsLoading ? ( + + ) : ( + + )} + + + )} +
+ + {/* Phones */} + +
+ {draftPhones.map((phone) => ( + + setDraftPhones((prev) => + prev.map((p) => (p.draftId === updated.draftId ? updated : p)) + ) + } + onDelete={() => handleDeletePhone(phone)} + disabled={isSaving} + typeOptions={phoneTypeOptions} + /> + ))} +
+
+ +
+
+ + {/* Emails */} + +
+ {draftEmails.map((email) => ( + + setDraftEmails((prev) => + prev.map((e) => (e.draftId === updated.draftId ? updated : e)) + ) + } + onDelete={() => handleDeleteEmail(email)} + disabled={isSaving} + typeOptions={emailTypeOptions} + /> + ))} +
+
+ +
+
+ + {/* Addresses */} + + {draftAddresses.map((address) => ( + + setDraftAddresses((prev) => + prev.map((a) => + a.draftId === updated.draftId ? updated : a + ) + ) + } + onDelete={() => handleDeleteAddress(address)} + disabled={isSaving} + typeOptions={addressTypeOptions} + /> + ))} +
+ +
+
+
+ + + + + Discard unsaved changes? + + Changes you have not saved will be lost. + + + + Keep editing + + Discard + + + + + + ) +} diff --git a/src/components/WellEdit/WellEditPanel.tsx b/src/components/WellEdit/WellEditPanel.tsx index 66314205..ad5bf49d 100644 --- a/src/components/WellEdit/WellEditPanel.tsx +++ b/src/components/WellEdit/WellEditPanel.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { useCustomMutation, useList, useNotification } from '@refinedev/core' import { useQueryClient } from '@tanstack/react-query' +import { captureEvent } from '@/analytics/posthog' import { Loader2, XIcon } from 'lucide-react' import { Button, buttonVariants } from '@/components/ui/button' import { @@ -137,6 +138,10 @@ export function WellEditPanel({ const isGroupsLoading = groupsQuery.isLoading const isLoading = isAssignedGroupsLoading || isGroupsLoading + useEffect(() => { + captureEvent('edit_panel_opened', { resource: 'well', well_id: wellId }) + }, [wellId]) + useEffect(() => { wasLoadingRef.current = true }, [wellId]) @@ -221,6 +226,11 @@ export function WellEditPanel({ ]) await invalidateWellDetails(queryClient, wellId) + captureEvent('edit_saved', { + resource: 'well', + well_id: wellId, + fields_changed: ['groups'], + }) onClose() } catch { notify?.({ @@ -245,6 +255,11 @@ export function WellEditPanel({ } const handleDiscardChanges = () => { + captureEvent('edit_abandoned', { + resource: 'well', + well_id: wellId, + had_changes: isDirty, + }) onClose() } diff --git a/src/components/editing/EditPanel.tsx b/src/components/editing/EditPanel.tsx index 205d73bf..797e2a93 100644 --- a/src/components/editing/EditPanel.tsx +++ b/src/components/editing/EditPanel.tsx @@ -29,7 +29,7 @@ export function EditPanel({ -
{children}
+
{children}
{footer ? (
diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 6030fbb9..06f6a959 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -11,7 +11,7 @@ const buttonVariants = cva( variant: { default: "bg-primary text-primary-foreground hover:bg-primary/80", outline: - "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", + "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:bg-input/30 dark:hover:bg-input/50", secondary: "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", ghost: diff --git a/src/hooks/useLexicon.ts b/src/hooks/useLexicon.ts index ae85252c..d69bf556 100644 --- a/src/hooks/useLexicon.ts +++ b/src/hooks/useLexicon.ts @@ -9,6 +9,7 @@ export const useLexicon = ({ category }: UseLexiconProps) => { const data = useList({ resource: 'lexicon/term', dataProviderName: 'ocotillo', + pagination: { pageSize: 500 }, queryOptions: { gcTime: 1000 * 60 * 5, // 5 minutes staleTime: 1000 * 60 * 2, // 2 minutes diff --git a/src/pages/ocotillo/contact/create.tsx b/src/pages/ocotillo/contact/create.tsx deleted file mode 100644 index 9b5cbc22..00000000 --- a/src/pages/ocotillo/contact/create.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import type { HttpError } from '@refinedev/core' -import { Create, useAutocomplete } from '@refinedev/mui' -import { useForm } from '@refinedev/react-hook-form' -import { Controller } from 'react-hook-form' -import type { Resolver } from 'react-hook-form' -import { Autocomplete, TextField } from '@mui/material' -import Grid from '@mui/material/Grid2' -import { Nullable } from '../../../interfaces' -import { zodResolver } from '@hookform/resolvers/zod' -import { zCreateContact } from '@/generated/zod.gen' -import { CreateContact } from '@/generated/types.gen' -import { ThingResponse } from '@/generated/types.gen' -import { CreateEditContact } from '@/components/form/contact/CreateEditContact' - -export const ContactCreate: React.FC = () => { - const { - saveButtonProps, - control, - formState: { errors }, - } = useForm>({ - resolver: zodResolver(zCreateContact) as Resolver< - Nullable, - {}, - Nullable - >, - mode: "onSubmit", - }) - - const { autocompleteProps } = useAutocomplete({ - resource: 'thing', - dataProviderName: 'ocotillo', - onSearch: (value) => [ - { - field: 'name', - operator: 'contains', - value, - }, - ], - }) - - return ( - - - - ( - option.id === field.value - ) || null - } - onChange={(_, newValue: any) => { - field.onChange(newValue?.id || null) - }} - getOptionKey={(option: any) => option.id} - getOptionLabel={(option: any) => option.name || ''} - renderInput={(params) => ( - - )} - /> - )} - /> - - - - - - - - ) -} diff --git a/src/pages/ocotillo/contact/edit.tsx b/src/pages/ocotillo/contact/edit.tsx deleted file mode 100644 index 26784c0e..00000000 --- a/src/pages/ocotillo/contact/edit.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import type { HttpError } from '@refinedev/core' -import { Edit, useAutocomplete } from '@refinedev/mui' -import { useForm } from '@refinedev/react-hook-form' -import { Controller } from 'react-hook-form' -import { Autocomplete, TextField } from '@mui/material' -import Grid from '@mui/material/Grid2' -import { useState, useEffect } from 'react' - -import type { Nullable } from '@/interfaces' -import { IContact } from '@/interfaces/ocotillo/IContact' -import { IThing } from '@/interfaces/ocotillo/IThing' -import { CreateEditContact } from '@/components/form/contact/CreateEditContact' - -export const ContactEdit: React.FC = () => { - const { - saveButtonProps, - refineCore: { query }, - control, - formState: { errors }, - watch, - } = useForm>() - - const [thingValue, setThingValue] = useState(null) - - const { autocompleteProps } = useAutocomplete({ - resource: 'thing', - dataProviderName: 'ocotillo', - onSearch: (value) => [ - { - field: 'name', - operator: 'contains', - value, - }, - ], - }) - /** - * @TODO this doesn't seems like the best method to get the thing id into the autocomplete - * @refactor - */ - useEffect(() => { - if ( - query?.data?.data?.things && - query.data.data.things.length > 0 - ) { - const thing = query.data.data.things[0] - setThingValue(thing) - } - }, [query?.data?.data?.things]) - - return ( - - - - ( - { - setThingValue(newValue) - field.onChange(newValue?.id || null) - }} - getOptionKey={(option: any) => option.id} - getOptionLabel={(option: any) => option.name || ''} - renderInput={(params) => ( - - )} - /> - )} - /> - - - - - - - - ) -} diff --git a/src/pages/ocotillo/contact/index.tsx b/src/pages/ocotillo/contact/index.tsx index de83fe16..1f96e3a6 100644 --- a/src/pages/ocotillo/contact/index.tsx +++ b/src/pages/ocotillo/contact/index.tsx @@ -1,4 +1,2 @@ export * from './list' -export * from './create' -export * from './edit' export * from './show' diff --git a/src/pages/ocotillo/contact/list.tsx b/src/pages/ocotillo/contact/list.tsx index b2890319..900853ba 100644 --- a/src/pages/ocotillo/contact/list.tsx +++ b/src/pages/ocotillo/contact/list.tsx @@ -10,9 +10,7 @@ import { } from '@/interfaces/ocotillo/IContact' import { Card, CardHeader, SxProps } from '@mui/material' import { Email, Home, Phone } from '@mui/icons-material' -import { Plus } from 'lucide-react' -import { Button } from '@/components/ui/button' -import { useLink, useNavigation } from '@refinedev/core' +import { useLink } from '@refinedev/core' import { settings } from '@/settings' import { formatAppDateTime, formatPhone } from '@/utils' import { getContactDisplayName } from '@/utils/contactDisplayName' @@ -44,7 +42,6 @@ export const ContactList: React.FC = () => { [canViewConfidential, dataGridProps.rows] ) - const { create } = useNavigation() const Link = useLink() const columns = useMemo[]>( @@ -193,15 +190,6 @@ export const ContactList: React.FC = () => { meta: { enabled: !!selectedContactId }, }) - const customHeaderButtons = () => ( - <> - - - ) - return ( <> { columns={columns} dataGridProps={{ ...dataGridPropsWithAnalytics, rows: visibleContacts }} getRowId={(row) => row.id} - headerButtons={customHeaderButtons} + hideHeaderButtons onRowClick={(params) => captureEvent('contacts_row_clicked', { contact_id: params.id }) } diff --git a/src/pages/ocotillo/contact/show.tsx b/src/pages/ocotillo/contact/show.tsx index 2027b69d..2a79c212 100644 --- a/src/pages/ocotillo/contact/show.tsx +++ b/src/pages/ocotillo/contact/show.tsx @@ -1,10 +1,14 @@ import { useShow } from '@refinedev/core' import { Show } from '@refinedev/mui' -import { useAccessCapabilities } from '@/hooks' +import { useResourceParams } from '@refinedev/core' +import { PencilIcon } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { useAccessCapabilities, useSidebarPanelSync } from '@/hooks' import { sanitizeContact } from '@/utils' import { getContactDisplayName } from '@/utils/contactDisplayName' -import { Chip, Stack } from '@mui/material' +import { Chip } from '@mui/material' import Grid from '@mui/material/Grid2' +import { Stack } from '@mui/material' import { IContact } from '@/interfaces/ocotillo' import { ContactDetailsCard, @@ -13,12 +17,16 @@ import { } from '@/components/ContactShow' import { ocotilloCardHeaderProps, + OcotilloHeaderButtons, OcotilloPageTitle, } from '@/components/OcotilloPageHeader' +import { EditPanelLayout } from '@/components/editing' +import { ContactEditPanel } from '@/components/ContactEdit/ContactEditPanel' export const ContactShow = () => { + const { id } = useResourceParams() const { query, result } = useShow({}) - const { canViewConfidential } = useAccessCapabilities() + const { canViewConfidential, canEditAmp } = useAccessCapabilities() const rawRecord: IContact | undefined = result const record = rawRecord != null @@ -27,56 +35,91 @@ export const ContactShow = () => { const contact = record + const { + isPanelOpen: isEditPanelOpen, + closePanel: closeEditPanel, + togglePanel: toggleEditPanel, + } = useSidebarPanelSync() + return ( - - {contact?.role ? ( - - ) : null} - {contact?.organization ? ( - - ) : null} - + query.refetch()} + /> + ) : null } - headerProps={ocotilloCardHeaderProps} - contentProps={{ sx: { pt: 1 } }} - headerButtons={() => null} > - - - {/* Left column: 8 cols */} - - - - - - + + {contact?.role ? ( + + ) : null} + {contact?.organization ? ( + + ) : null} + + } + headerProps={ocotilloCardHeaderProps} + contentProps={{ sx: { pt: 1 } }} + headerButtons={() => ( + + {canEditAmp ? ( + + ) : null} + + )} + > + + + {/* Left column: 8 cols */} + + + + + + - {/* Right column: 4 cols */} - - - - + {/* Right column: 4 cols */} + + + + + - - - + + + ) } diff --git a/src/pages/ocotillo/thing/edit.tsx b/src/pages/ocotillo/thing/edit.tsx deleted file mode 100644 index fe308fe4..00000000 --- a/src/pages/ocotillo/thing/edit.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import type { HttpError } from '@refinedev/core' -import { Edit } from '@refinedev/mui' -import { useForm } from '@refinedev/react-hook-form' - -import type { Nullable } from '@/interfaces' -import { IWell } from '@/interfaces/ocotillo' -import { CreateEditWell } from '@/components/form/thing/CreateEditWell' - -export const WellEdit: React.FC = () => { - const { - saveButtonProps, - control, - formState: { errors }, - } = useForm>() - - return ( - - - - ) -} diff --git a/src/pages/ocotillo/thing/index.tsx b/src/pages/ocotillo/thing/index.tsx index e8bce88f..7fd35514 100644 --- a/src/pages/ocotillo/thing/index.tsx +++ b/src/pages/ocotillo/thing/index.tsx @@ -1,6 +1,5 @@ export * from './list' export * from './create' -export * from './edit' export * from './well-show' export * from './well-show-pdf-preview' export * from './well-batch-export' diff --git a/src/resources/ocotillo.tsx b/src/resources/ocotillo.tsx index ba54d204..3ec1f361 100644 --- a/src/resources/ocotillo.tsx +++ b/src/resources/ocotillo.tsx @@ -32,7 +32,6 @@ let tables: { { name: 'thing-well', list: '/ocotillo/well', - edit: '/ocotillo/well/edit/:id', show: '/ocotillo/well/show/:id', create: '/ocotillo/well/create', meta: { @@ -54,9 +53,7 @@ let tables: { { name: 'contact', list: '/ocotillo/contact', - edit: '/ocotillo/contact/edit/:id', show: '/ocotillo/contact/show/:id', - create: '/ocotillo/contact/create', meta: { icon: , label: 'Contacts & Owners', diff --git a/src/routes/ocotillo.tsx b/src/routes/ocotillo.tsx index 86380dd0..768c0dbf 100644 --- a/src/routes/ocotillo.tsx +++ b/src/routes/ocotillo.tsx @@ -1,16 +1,13 @@ import { Route, Routes } from 'react-router' import { ErrorComponent } from '@refinedev/mui' import { - ContactEdit, ContactList, ContactShow, - ContactCreate, } from '@/pages/ocotillo/contact' import { SpringList, SpringCreate, WellCreate, - WellEdit, WellList, WellShow, WellShowPdfPreview, @@ -88,8 +85,6 @@ export const OcotilloRoutes = () => { } /> } /> - } /> - } /> } /> @@ -128,7 +123,6 @@ export const OcotilloRoutes = () => { } /> - } /> } /> diff --git a/src/test/components/ContactEditPanel.test.tsx b/src/test/components/ContactEditPanel.test.tsx new file mode 100644 index 00000000..df30c67a --- /dev/null +++ b/src/test/components/ContactEditPanel.test.tsx @@ -0,0 +1,849 @@ +// @vitest-environment jsdom +import React from 'react' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const captureEventMock = vi.fn() +const updateMutateAsyncMock = vi.fn() +const customMutateMock = vi.fn() +const invalidateMock = vi.fn() +const notifyMock = vi.fn() +const onCloseMock = vi.fn() + +vi.mock('@/analytics/posthog', () => ({ + captureEvent: (...args: unknown[]) => captureEventMock(...args), +})) + +vi.mock('@refinedev/core', () => ({ + useUpdate: () => ({ + mutateAsync: updateMutateAsyncMock, + mutation: { isPending: false }, + }), + useCustomMutation: () => ({ + mutateAsync: customMutateMock, + mutation: { isPending: false }, + }), + useInvalidate: () => invalidateMock, + useNotification: () => ({ open: notifyMock }), +})) + +vi.mock('@/hooks', () => ({ + useLexicon: ({ category }: { category: string }) => { + const options: Record = { + role: [ + { value: 'Owner', label: 'Owner' }, + { value: 'Manager', label: 'Manager' }, + ], + contact_type: [ + { value: 'Primary', label: 'Primary' }, + { value: 'Secondary', label: 'Secondary' }, + ], + email_type: [ + { value: 'Primary', label: 'Primary' }, + { value: 'Work', label: 'Work' }, + ], + phone_type: [ + { value: 'Primary', label: 'Primary' }, + { value: 'Mobile', label: 'Mobile' }, + ], + address_type: [ + { value: 'Mailing', label: 'Mailing' }, + { value: 'Physical', label: 'Physical' }, + ], + organization: [ + { value: 'NMBGMR', label: 'NMBGMR' }, + { value: 'Bureau of Geology', label: 'Bureau of Geology' }, + ], + } + return { options: options[category] ?? [], isLoading: false } + }, +})) + +vi.mock('@/components/editing', () => ({ + EditPanel: ({ + title, + children, + footer, + onClose, + }: { + title: string + children: React.ReactNode + footer?: React.ReactNode + onClose: () => void + }) => ( +
+ {title} + +
{children}
+ {footer &&
{footer}
} +
+ ), + EditPanelSection: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + EditPanelField: ({ + label, + children, + }: { + label: string + children: React.ReactNode + }) => ( +
+ + {children} +
+ ), +})) + +vi.mock('@/components/ui/select', () => ({ + Select: ({ + value, + onValueChange, + children, + disabled, + }: { + value?: string + onValueChange?: (v: string) => void + children: React.ReactNode + disabled?: boolean + }) => ( + + ), + SelectTrigger: () => null, + SelectValue: () => null, + SelectContent: ({ children }: { children: React.ReactNode }) => ( + <>{children} + ), + SelectItem: ({ + value, + children, + }: { + value: string + children: React.ReactNode + }) => , +})) + +vi.mock('@/components/ui/alert-dialog', () => ({ + AlertDialog: ({ + open, + children, + }: { + open: boolean + children: React.ReactNode + }) => (open ?
{children}
: null), + AlertDialogContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + AlertDialogHeader: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + AlertDialogTitle: ({ children }: { children: React.ReactNode }) => ( +

{children}

+ ), + AlertDialogDescription: ({ children }: { children: React.ReactNode }) => ( +

{children}

+ ), + AlertDialogFooter: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + AlertDialogCancel: ({ children }: { children: React.ReactNode }) => ( + + ), + AlertDialogAction: ({ + onClick, + children, + }: { + onClick: () => void + children: React.ReactNode + }) => , +})) + +vi.mock('@/components/ui/skeleton', () => ({ + Skeleton: () =>
, +})) + +import { ContactEditPanel } from '@/components/ContactEdit/ContactEditPanel' +import type { IContact } from '@/interfaces/ocotillo' + +// ─── Test fixtures ──────────────────────────────────────────────────────────── + +const SAMPLE_CONTACT: IContact = { + id: 7, + name: 'Rachel Benjamin', + organization: 'NMBGMR', + role: 'Owner', + contact_type: 'Primary', + created_at: new Date('2026-01-01'), + release_status: 'public', +} + +const CONTACT_WITH_EMAIL: IContact = { + ...SAMPLE_CONTACT, + emails: [ + { + id: 101, + email: 'rachel@nmbgmr.gov', + email_type: 'Primary', + contact_id: 7, + created_at: new Date('2026-01-01'), + release_status: 'public', + }, + ], +} + +const CONTACT_WITH_PHONE: IContact = { + ...SAMPLE_CONTACT, + phones: [ + { + id: 201, + phone_number: '5055550001', + phone_type: 'Primary', + contact_id: 7, + created_at: new Date('2026-01-01'), + release_status: 'public', + }, + ], +} + +const CONTACT_WITH_ADDRESS: IContact = { + ...SAMPLE_CONTACT, + addresses: [ + { + id: 301, + address_line_1: '801 Leroy Place', + city: 'Socorro', + state: 'NM', + postal_code: '87801', + country: 'United States', + address_type: 'Mailing', + contact_id: 7, + created_at: new Date('2026-01-01'), + release_status: 'public', + }, + ], +} + +const renderPanel = (contact: IContact = SAMPLE_CONTACT) => + render( + + ) + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('ContactEditPanel', () => { + beforeEach(() => { + captureEventMock.mockClear() + updateMutateAsyncMock.mockClear() + customMutateMock.mockClear() + invalidateMock.mockClear() + notifyMock.mockClear() + onCloseMock.mockClear() + updateMutateAsyncMock.mockResolvedValue({}) + customMutateMock.mockResolvedValue({}) + invalidateMock.mockResolvedValue(undefined) + }) + + describe('PostHog events', () => { + it('fires edit_panel_opened with resource and contact_id on mount', () => { + renderPanel() + expect(captureEventMock).toHaveBeenCalledWith('edit_panel_opened', { + resource: 'contact', + contact_id: SAMPLE_CONTACT.id, + }) + }) + + it('fires edit_saved with contact_details section after saving basic fields', async () => { + const user = userEvent.setup() + renderPanel() + + const nameInput = screen.getByDisplayValue('Rachel Benjamin') + await user.clear(nameInput) + await user.type(nameInput, 'Rachel B.') + await user.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => { + expect(captureEventMock).toHaveBeenCalledWith( + 'edit_saved', + expect.objectContaining({ + resource: 'contact', + contact_id: SAMPLE_CONTACT.id, + fields_changed: ['contact_details'], + }) + ) + }) + }) + + it('fires edit_saved with emails section after deleting an email', async () => { + const user = userEvent.setup() + renderPanel(CONTACT_WITH_EMAIL) + + await user.click( + screen.getByRole('button', { name: /Remove email rachel@nmbgmr.gov/i }) + ) + await user.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => { + expect(captureEventMock).toHaveBeenCalledWith( + 'edit_saved', + expect.objectContaining({ + fields_changed: expect.arrayContaining(['emails']), + }) + ) + }) + }) + + it('fires edit_abandoned with had_changes:true when the user discards unsaved changes', async () => { + const user = userEvent.setup() + renderPanel() + + const nameInput = screen.getByDisplayValue('Rachel Benjamin') + await user.clear(nameInput) + await user.type(nameInput, 'Changed') + await user.click(screen.getByTestId('panel-close')) + await user.click(screen.getByRole('button', { name: 'Discard' })) + + expect(captureEventMock).toHaveBeenCalledWith( + 'edit_abandoned', + expect.objectContaining({ + resource: 'contact', + contact_id: SAMPLE_CONTACT.id, + had_changes: true, + }) + ) + }) + + it('does not fire edit_abandoned when closing without any changes', async () => { + const user = userEvent.setup() + renderPanel() + await user.click(screen.getByTestId('panel-close')) + + expect(captureEventMock).not.toHaveBeenCalledWith( + 'edit_abandoned', + expect.anything() + ) + }) + }) + + describe('panel title', () => { + it('shows the contact display name in the title', () => { + renderPanel() + expect(screen.getByTestId('panel-title')).toHaveTextContent( + 'Edit: Rachel Benjamin' + ) + }) + + it('falls back to Edit when no contact is provided', () => { + render( + + ) + expect(screen.getByTestId('panel-title')).toHaveTextContent('Edit') + }) + }) + + describe('field pre-population', () => { + it('fills the name and organization inputs from the contact prop', () => { + renderPanel() + expect(screen.getByDisplayValue('Rachel Benjamin')).toBeTruthy() + expect(screen.getByDisplayValue('NMBGMR')).toBeTruthy() + }) + }) + + describe('save button state', () => { + it('disables Save when no fields have changed', () => { + renderPanel() + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled() + }) + + it('enables Save after editing the name field', async () => { + const user = userEvent.setup() + renderPanel() + const nameInput = screen.getByDisplayValue('Rachel Benjamin') + await user.clear(nameInput) + await user.type(nameInput, 'New Name') + expect(screen.getByRole('button', { name: 'Save' })).not.toBeDisabled() + }) + + it('enables Save after editing the organization field', async () => { + const user = userEvent.setup() + renderPanel() + const orgSelect = screen.getByDisplayValue('NMBGMR') + await user.selectOptions(orgSelect, 'Bureau of Geology') + expect(screen.getByRole('button', { name: 'Save' })).not.toBeDisabled() + }) + + it('re-disables Save if the user reverts their changes', async () => { + const user = userEvent.setup() + renderPanel() + const nameInput = screen.getByDisplayValue('Rachel Benjamin') + await user.clear(nameInput) + await user.type(nameInput, 'Temp Name') + await user.clear(nameInput) + await user.type(nameInput, 'Rachel Benjamin') + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled() + }) + }) + + describe('saving contact details', () => { + it('sends only the changed field to useUpdate', async () => { + const user = userEvent.setup() + renderPanel() + + const orgSelect = screen.getByDisplayValue('NMBGMR') + await user.selectOptions(orgSelect, 'Bureau of Geology') + await user.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => { + expect(updateMutateAsyncMock).toHaveBeenCalledWith( + expect.objectContaining({ + resource: 'contact', + dataProviderName: 'ocotillo', + id: SAMPLE_CONTACT.id, + values: { organization: 'Bureau of Geology' }, + }) + ) + }) + }) + + it('invalidates the contact detail and list after a successful save', async () => { + const user = userEvent.setup() + renderPanel() + + const nameInput = screen.getByDisplayValue('Rachel Benjamin') + await user.clear(nameInput) + await user.type(nameInput, 'New Name') + await user.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => { + expect(invalidateMock).toHaveBeenCalledWith( + expect.objectContaining({ + resource: 'contact', + id: SAMPLE_CONTACT.id, + invalidates: ['detail', 'list'], + }) + ) + }) + }) + + it('calls onClose after a successful save', async () => { + const user = userEvent.setup() + renderPanel() + + const nameInput = screen.getByDisplayValue('Rachel Benjamin') + await user.clear(nameInput) + await user.type(nameInput, 'New Name') + await user.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => expect(onCloseMock).toHaveBeenCalled()) + }) + }) + + describe('close and discard behavior', () => { + it('calls onClose immediately when there are no unsaved changes', async () => { + const user = userEvent.setup() + renderPanel() + await user.click(screen.getByTestId('panel-close')) + expect(onCloseMock).toHaveBeenCalled() + }) + + it('shows the discard dialog when closing with unsaved changes', async () => { + const user = userEvent.setup() + renderPanel() + + const nameInput = screen.getByDisplayValue('Rachel Benjamin') + await user.clear(nameInput) + await user.type(nameInput, 'Changed') + await user.click(screen.getByTestId('panel-close')) + + expect(screen.getByRole('alertdialog')).toBeTruthy() + expect(onCloseMock).not.toHaveBeenCalled() + }) + + it('calls onClose when the user confirms discard', async () => { + const user = userEvent.setup() + renderPanel() + + const nameInput = screen.getByDisplayValue('Rachel Benjamin') + await user.clear(nameInput) + await user.type(nameInput, 'Changed') + await user.click(screen.getByTestId('panel-close')) + await user.click(screen.getByRole('button', { name: 'Discard' })) + + expect(onCloseMock).toHaveBeenCalled() + }) + + it('does not close when the user cancels the discard dialog', async () => { + const user = userEvent.setup() + renderPanel() + + const nameInput = screen.getByDisplayValue('Rachel Benjamin') + await user.clear(nameInput) + await user.type(nameInput, 'Changed') + await user.click(screen.getByTestId('panel-close')) + await user.click(screen.getByRole('button', { name: 'Keep editing' })) + + expect(onCloseMock).not.toHaveBeenCalled() + }) + }) + + describe('email section', () => { + it('renders existing email addresses from the contact', () => { + renderPanel(CONTACT_WITH_EMAIL) + expect(screen.getByDisplayValue('rachel@nmbgmr.gov')).toBeTruthy() + }) + + it('keeps Save disabled when an empty email row is added', async () => { + const user = userEvent.setup() + renderPanel() + await user.click(screen.getByRole('button', { name: /Add email/i })) + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled() + }) + + it('enables Save after typing into a new email row', async () => { + const user = userEvent.setup() + renderPanel() + await user.click(screen.getByRole('button', { name: /Add email/i })) + await user.type( + screen.getByPlaceholderText('name@example.com'), + 'new@example.com' + ) + expect(screen.getByRole('button', { name: 'Save' })).not.toBeDisabled() + }) + + it('does not show a validation error while typing an invalid email before blur', async () => { + const user = userEvent.setup() + renderPanel() + await user.click(screen.getByRole('button', { name: /Add email/i })) + await user.type(screen.getByPlaceholderText('name@example.com'), 'notvalid') + // role="alert" is only added once the field has been blurred + expect(screen.queryByRole('alert')).toBeNull() + }) + + it('shows a validation error after blurring an invalid email field', async () => { + const user = userEvent.setup() + renderPanel() + await user.click(screen.getByRole('button', { name: /Add email/i })) + const input = screen.getByPlaceholderText('name@example.com') + await user.type(input, 'notvalid') + await user.tab() // triggers blur + expect(screen.getByRole('alert')).toHaveTextContent('Enter a valid email address.') + }) + + it('disables Save when a new email row has an invalid format', async () => { + const user = userEvent.setup() + renderPanel() + await user.click(screen.getByRole('button', { name: /Add email/i })) + await user.type(screen.getByPlaceholderText('name@example.com'), 'notvalid') + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled() + }) + + it('enables Save when an existing email is deleted', async () => { + const user = userEvent.setup() + renderPanel(CONTACT_WITH_EMAIL) + await user.click( + screen.getByRole('button', { name: /Remove email rachel@nmbgmr.gov/i }) + ) + expect(screen.getByRole('button', { name: 'Save' })).not.toBeDisabled() + }) + + it('removes the email row from view after deletion', async () => { + const user = userEvent.setup() + renderPanel(CONTACT_WITH_EMAIL) + await user.click( + screen.getByRole('button', { name: /Remove email rachel@nmbgmr.gov/i }) + ) + expect(screen.queryByDisplayValue('rachel@nmbgmr.gov')).toBeNull() + }) + + it('sends DELETE mutation for a removed email on save', async () => { + const user = userEvent.setup() + renderPanel(CONTACT_WITH_EMAIL) + + await user.click( + screen.getByRole('button', { name: /Remove email rachel@nmbgmr.gov/i }) + ) + await user.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => { + expect(customMutateMock).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'contact/email/101', + method: 'delete', + }) + ) + }) + }) + + it('sends POST mutation for a new email on save', async () => { + const user = userEvent.setup() + renderPanel() + + await user.click(screen.getByRole('button', { name: /Add email/i })) + await user.type( + screen.getByPlaceholderText('name@example.com'), + 'new@example.com' + ) + await user.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => { + expect(customMutateMock).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'contact/email', + method: 'post', + values: expect.objectContaining({ + contact_id: SAMPLE_CONTACT.id, + email: 'new@example.com', + }), + }) + ) + }) + }) + + it('sends PATCH mutation for a modified email on save', async () => { + const user = userEvent.setup() + renderPanel(CONTACT_WITH_EMAIL) + + const emailInput = screen.getByDisplayValue('rachel@nmbgmr.gov') + await user.clear(emailInput) + await user.type(emailInput, 'rachel.updated@nmbgmr.gov') + await user.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => { + expect(customMutateMock).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'contact/email/101', + method: 'patch', + values: expect.objectContaining({ + email: 'rachel.updated@nmbgmr.gov', + }), + }) + ) + }) + }) + }) + + describe('phone section', () => { + it('renders existing phone numbers from the contact in display format', () => { + renderPanel(CONTACT_WITH_PHONE) + // 5055550001 is formatted as (505) 555-0001 on load + expect(screen.getByDisplayValue('(505) 555-0001')).toBeTruthy() + }) + + it('keeps Save disabled when an empty phone row is added', async () => { + const user = userEvent.setup() + renderPanel() + await user.click(screen.getByRole('button', { name: /Add phone/i })) + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled() + }) + + it('enables Save after typing a valid number into a new phone row', async () => { + const user = userEvent.setup() + renderPanel() + await user.click(screen.getByRole('button', { name: /Add phone/i })) + await user.type( + screen.getByPlaceholderText('(505) 555-0100'), + '5055559999' + ) + expect(screen.getByRole('button', { name: 'Save' })).not.toBeDisabled() + }) + + it('does not show a validation error while typing an incomplete number before blur', async () => { + const user = userEvent.setup() + renderPanel() + await user.click(screen.getByRole('button', { name: /Add phone/i })) + await user.type(screen.getByPlaceholderText('(505) 555-0100'), '505') + // role="alert" is only added once the field has been blurred + expect(screen.queryByRole('alert')).toBeNull() + }) + + it('shows a validation error after blurring an incomplete phone field', async () => { + const user = userEvent.setup() + renderPanel() + await user.click(screen.getByRole('button', { name: /Add phone/i })) + const input = screen.getByPlaceholderText('(505) 555-0100') + await user.type(input, '505') + await user.tab() // triggers blur + expect(screen.getByRole('alert')).toHaveTextContent('Enter a 10-digit US phone number.') + }) + + it('disables Save when a phone row has fewer than 10 digits', async () => { + const user = userEvent.setup() + renderPanel() + await user.click(screen.getByRole('button', { name: /Add phone/i })) + await user.type(screen.getByPlaceholderText('(505) 555-0100'), '505') + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled() + }) + + it('enables Save when an existing phone is deleted', async () => { + const user = userEvent.setup() + renderPanel(CONTACT_WITH_PHONE) + await user.click( + screen.getByRole('button', { name: /Remove phone \(505\) 555-0001/i }) + ) + expect(screen.getByRole('button', { name: 'Save' })).not.toBeDisabled() + }) + + it('sends DELETE mutation for a removed phone on save', async () => { + const user = userEvent.setup() + renderPanel(CONTACT_WITH_PHONE) + + await user.click( + screen.getByRole('button', { name: /Remove phone \(505\) 555-0001/i }) + ) + await user.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => { + expect(customMutateMock).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'contact/phone/201', + method: 'delete', + }) + ) + }) + }) + + it('sends POST mutation with E.164 format for a new phone on save', async () => { + const user = userEvent.setup() + renderPanel() + + await user.click(screen.getByRole('button', { name: /Add phone/i })) + await user.type( + screen.getByPlaceholderText('(505) 555-0100'), + '5055559999' + ) + await user.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => { + expect(customMutateMock).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'contact/phone', + method: 'post', + values: expect.objectContaining({ + contact_id: SAMPLE_CONTACT.id, + phone_number: '+15055559999', + }), + }) + ) + }) + }) + }) + + describe('address section', () => { + it('renders existing address fields from the contact', () => { + renderPanel(CONTACT_WITH_ADDRESS) + expect(screen.getByDisplayValue('801 Leroy Place')).toBeTruthy() + expect(screen.getByDisplayValue('Socorro')).toBeTruthy() + }) + + it('keeps Save disabled when an empty address block is added', async () => { + const user = userEvent.setup() + renderPanel() + await user.click(screen.getByRole('button', { name: /Add address/i })) + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled() + }) + + it('enables Save after typing an address line into a new block', async () => { + const user = userEvent.setup() + renderPanel() + await user.click(screen.getByRole('button', { name: /Add address/i })) + await user.type( + screen.getByRole('textbox', { name: /Address line 1/i }), + '123 Main St' + ) + expect(screen.getByRole('button', { name: 'Save' })).not.toBeDisabled() + }) + + it('enables Save when an existing address is deleted', async () => { + const user = userEvent.setup() + renderPanel(CONTACT_WITH_ADDRESS) + await user.click( + screen.getByRole('button', { name: /Remove address 801 Leroy Place/i }) + ) + expect(screen.getByRole('button', { name: 'Save' })).not.toBeDisabled() + }) + + it('sends DELETE mutation for a removed address on save', async () => { + const user = userEvent.setup() + renderPanel(CONTACT_WITH_ADDRESS) + + await user.click( + screen.getByRole('button', { name: /Remove address 801 Leroy Place/i }) + ) + await user.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => { + expect(customMutateMock).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'contact/address/301', + method: 'delete', + }) + ) + }) + }) + + it('sends POST mutation for a new address on save', async () => { + const user = userEvent.setup() + renderPanel() + + await user.click(screen.getByRole('button', { name: /Add address/i })) + await user.type( + screen.getByRole('textbox', { name: /Address line 1/i }), + '123 Main St' + ) + await user.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => { + expect(customMutateMock).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'contact/address', + method: 'post', + values: expect.objectContaining({ + contact_id: SAMPLE_CONTACT.id, + address_line_1: '123 Main St', + }), + }) + ) + }) + }) + + it('sends PATCH mutation for a modified address on save', async () => { + const user = userEvent.setup() + renderPanel(CONTACT_WITH_ADDRESS) + + const cityInput = screen.getByDisplayValue('Socorro') + await user.clear(cityInput) + await user.type(cityInput, 'Albuquerque') + await user.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => { + expect(customMutateMock).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'contact/address/301', + method: 'patch', + values: expect.objectContaining({ + city: 'Albuquerque', + }), + }) + ) + }) + }) + }) +}) diff --git a/src/test/components/WellEditPanel.test.tsx b/src/test/components/WellEditPanel.test.tsx new file mode 100644 index 00000000..77678493 --- /dev/null +++ b/src/test/components/WellEditPanel.test.tsx @@ -0,0 +1,393 @@ +// @vitest-environment jsdom +import React from 'react' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const captureEventMock = vi.fn() +const mutateGroupThingMock = vi.fn() +const invalidateWellDetailsMock = vi.fn() +const notifyMock = vi.fn() +const onCloseMock = vi.fn() + +const queryClientMock = { + getQueryData: vi.fn(), + invalidateQueries: vi.fn(), +} + +vi.mock('@/analytics/posthog', () => ({ + captureEvent: (...args: unknown[]) => captureEventMock(...args), +})) + +vi.mock('@refinedev/core', () => ({ + useCustomMutation: () => ({ + mutateAsync: mutateGroupThingMock, + mutation: { isPending: false }, + }), + useList: () => ({ + result: { + data: [ + { id: 10, name: 'Available Project', group_type: 'Monitoring' }, + { id: 11, name: 'Another Project', group_type: null }, + ], + }, + query: { isLoading: false }, + }), + useNotification: () => ({ open: notifyMock }), +})) + +vi.mock('@tanstack/react-query', () => ({ + useQueryClient: () => queryClientMock, +})) + +vi.mock('@/hooks', () => ({ + invalidateWellDetails: (...args: unknown[]) => + invalidateWellDetailsMock(...args), + wellDetailsQueryKey: (id: unknown) => ['wells', id], +})) + +vi.mock('@/components/editing', () => ({ + EditPanel: ({ + title, + children, + footer, + onClose, + }: { + title: string + children: React.ReactNode + footer?: React.ReactNode + onClose: () => void + }) => ( +
+ {title} + +
{children}
+ {footer &&
{footer}
} +
+ ), + EditPanelSection: ({ + title, + children, + }: { + title: string + children: React.ReactNode + }) => ( +
+

{title}

+ {children} +
+ ), + EditPanelField: ({ + label, + children, + }: { + label: string + children: React.ReactNode + }) => ( +
+ + {children} +
+ ), +})) + +vi.mock('@/components/ui/select', () => ({ + Select: ({ + value, + onValueChange, + children, + disabled, + }: { + value?: string + onValueChange?: (v: string) => void + children: React.ReactNode + disabled?: boolean + }) => ( + + ), + SelectTrigger: () => null, + SelectValue: ({ placeholder }: { placeholder?: string }) => ( + + ), + SelectContent: ({ children }: { children: React.ReactNode }) => ( + <>{children} + ), + SelectItem: ({ + value, + children, + }: { + value: string + children: React.ReactNode + }) => , +})) + +vi.mock('@/components/ui/alert-dialog', () => ({ + AlertDialog: ({ + open, + children, + }: { + open: boolean + children: React.ReactNode + }) => (open ?
{children}
: null), + AlertDialogContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + AlertDialogHeader: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + AlertDialogTitle: ({ children }: { children: React.ReactNode }) => ( +

{children}

+ ), + AlertDialogDescription: ({ children }: { children: React.ReactNode }) => ( +

{children}

+ ), + AlertDialogFooter: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + AlertDialogCancel: ({ children }: { children: React.ReactNode }) => ( + + ), + AlertDialogAction: ({ + onClick, + children, + }: { + onClick: () => void + children: React.ReactNode + }) => , +})) + +vi.mock('@/components/ui/skeleton', () => ({ + Skeleton: () =>
, +})) + +import { WellEditPanel } from '@/components/WellEdit/WellEditPanel' +import type { IGroup } from '@/interfaces/ocotillo/IGroup' + +const GROUP_ALPHA: IGroup = { + id: 1, + name: 'Project Alpha', + group_type: 'Monitoring', + created_at: '2024-01-01T00:00:00Z', +} +const GROUP_BETA: IGroup = { + id: 2, + name: 'Project Beta', + group_type: null, + created_at: '2024-01-01T00:00:00Z', +} + +const renderPanel = (assignedGroups: IGroup[] = [GROUP_ALPHA]) => + render( + + ) + +describe('WellEditPanel', () => { + beforeEach(() => { + captureEventMock.mockClear() + mutateGroupThingMock.mockClear() + invalidateWellDetailsMock.mockClear() + notifyMock.mockClear() + onCloseMock.mockClear() + mutateGroupThingMock.mockResolvedValue({}) + invalidateWellDetailsMock.mockResolvedValue(undefined) + queryClientMock.getQueryData.mockReturnValue({ well: { groups: [GROUP_ALPHA] } }) + }) + + describe('PostHog events', () => { + it('fires edit_panel_opened with resource and well_id on mount', () => { + renderPanel() + expect(captureEventMock).toHaveBeenCalledWith('edit_panel_opened', { + resource: 'well', + well_id: 42, + }) + }) + + it('fires edit_saved after successfully saving group changes', async () => { + const user = userEvent.setup() + renderPanel() + + await user.click(screen.getByRole('button', { name: 'Remove Project Alpha' })) + await user.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => { + expect(captureEventMock).toHaveBeenCalledWith( + 'edit_saved', + expect.objectContaining({ + resource: 'well', + well_id: 42, + fields_changed: ['groups'], + }) + ) + }) + }) + + it('fires edit_abandoned with had_changes:true when the user discards unsaved changes', async () => { + const user = userEvent.setup() + renderPanel() + + await user.click(screen.getByRole('button', { name: 'Remove Project Alpha' })) + await user.click(screen.getByTestId('panel-close')) + await user.click(screen.getByRole('button', { name: 'Discard' })) + + expect(captureEventMock).toHaveBeenCalledWith( + 'edit_abandoned', + expect.objectContaining({ + resource: 'well', + well_id: 42, + had_changes: true, + }) + ) + }) + + it('does not fire edit_abandoned when closing without any changes', async () => { + const user = userEvent.setup() + renderPanel() + await user.click(screen.getByTestId('panel-close')) + + expect(captureEventMock).not.toHaveBeenCalledWith( + 'edit_abandoned', + expect.anything() + ) + }) + }) + + describe('panel title', () => { + it('shows Edit: {well name} in the title', () => { + renderPanel() + expect(screen.getByTestId('panel-title')).toHaveTextContent('Edit: Test Well') + }) + }) + + describe('assigned groups display', () => { + it('shows assigned group chips', () => { + renderPanel() + expect(screen.getByText('Project Alpha')).toBeTruthy() + }) + + it('shows multiple assigned groups', () => { + renderPanel([GROUP_ALPHA, GROUP_BETA]) + expect(screen.getByText('Project Alpha')).toBeTruthy() + expect(screen.getByText('Project Beta')).toBeTruthy() + }) + + it('shows "No projects assigned yet." when assignedGroups is empty', () => { + renderPanel([]) + expect(screen.getByText('No projects assigned yet.')).toBeTruthy() + }) + }) + + describe('save button state', () => { + it('disables Save when no groups have changed', () => { + renderPanel() + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled() + }) + + it('enables Save after removing a group', async () => { + const user = userEvent.setup() + renderPanel() + await user.click(screen.getByRole('button', { name: 'Remove Project Alpha' })) + expect(screen.getByRole('button', { name: 'Save' })).not.toBeDisabled() + }) + }) + + describe('saving', () => { + it('calls the delete mutation for removed groups', async () => { + const user = userEvent.setup() + renderPanel() + + await user.click(screen.getByRole('button', { name: 'Remove Project Alpha' })) + await user.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => { + expect(mutateGroupThingMock).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'group/1/things/42', + method: 'delete', + dataProviderName: 'ocotillo', + }) + ) + }) + }) + + it('invalidates well details after a successful save', async () => { + const user = userEvent.setup() + renderPanel() + + await user.click(screen.getByRole('button', { name: 'Remove Project Alpha' })) + await user.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => { + expect(invalidateWellDetailsMock).toHaveBeenCalledWith( + queryClientMock, + 42 + ) + }) + }) + + it('calls onClose after a successful save', async () => { + const user = userEvent.setup() + renderPanel() + + await user.click(screen.getByRole('button', { name: 'Remove Project Alpha' })) + await user.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => expect(onCloseMock).toHaveBeenCalled()) + }) + }) + + describe('close and discard behavior', () => { + it('calls onClose immediately when there are no unsaved changes', async () => { + const user = userEvent.setup() + renderPanel() + await user.click(screen.getByTestId('panel-close')) + expect(onCloseMock).toHaveBeenCalled() + }) + + it('shows the discard dialog when closing with unsaved changes', async () => { + const user = userEvent.setup() + renderPanel() + + await user.click(screen.getByRole('button', { name: 'Remove Project Alpha' })) + await user.click(screen.getByTestId('panel-close')) + + expect(screen.getByRole('alertdialog')).toBeTruthy() + expect(onCloseMock).not.toHaveBeenCalled() + }) + + it('calls onClose when the user confirms discard', async () => { + const user = userEvent.setup() + renderPanel() + + await user.click(screen.getByRole('button', { name: 'Remove Project Alpha' })) + await user.click(screen.getByTestId('panel-close')) + await user.click(screen.getByRole('button', { name: 'Discard' })) + + expect(onCloseMock).toHaveBeenCalled() + }) + + it('does not close when the user cancels the discard dialog', async () => { + const user = userEvent.setup() + renderPanel() + + await user.click(screen.getByRole('button', { name: 'Remove Project Alpha' })) + await user.click(screen.getByTestId('panel-close')) + await user.click(screen.getByRole('button', { name: 'Keep editing' })) + + expect(onCloseMock).not.toHaveBeenCalled() + }) + }) +}) diff --git a/src/test/setup.ts b/src/test/setup.ts index 4be2a917..d56a211b 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -1,3 +1,4 @@ +import '@testing-library/jest-dom' import { beforeAll, vi } from 'vitest' import { checkMockServerHealth } from './mock-server' import { ocotilloDataProvider } from '@/providers/ocotillo-data-provider'