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 (
+
+
+
+
+
+ {
+ 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 (
+
+ )
+}
+
+// ─── 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'