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/constants/usStates.ts b/src/constants/usStates.ts
new file mode 100644
index 00000000..86555765
--- /dev/null
+++ b/src/constants/usStates.ts
@@ -0,0 +1,53 @@
+export 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.' },
+] as const
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'
diff --git a/src/utils/FormatPhone.ts b/src/utils/FormatPhone.ts
index dbcafaec..50a2ae4a 100644
--- a/src/utils/FormatPhone.ts
+++ b/src/utils/FormatPhone.ts
@@ -14,3 +14,44 @@ export const formatPhone = (phone: string | null | undefined): string => {
}
return phone
}
+
+/**
+ * Formats a partial or complete 10-digit string as the user types.
+ * Produces (XXX), (XXX) XXX, or (XXX) XXX-XXXX progressively.
+ */
+export 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)}`
+}
+
+/**
+ * Converts an E.164 phone number (+1XXXXXXXXXX) to display format (XXX) XXX-XXXX.
+ */
+export 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)
+}
+
+/**
+ * Converts a display-formatted phone number to E.164 (+1XXXXXXXXXX).
+ * Returns the input unchanged if it does not contain exactly 10 digits.
+ */
+export function displayToE164(display: string): string {
+ const digits = display.replace(/\D/g, '')
+ return digits.length === 10 ? `+1${digits}` : display
+}
+
+/**
+ * Returns true if the display-formatted phone value is either empty or contains
+ * exactly 10 digits (a complete US number).
+ */
+export function isValidPhone(display: string): boolean {
+ if (!display.trim()) return true
+ return display.replace(/\D/g, '').length === 10
+}
diff --git a/src/utils/ValidateEmail.ts b/src/utils/ValidateEmail.ts
new file mode 100644
index 00000000..cda42127
--- /dev/null
+++ b/src/utils/ValidateEmail.ts
@@ -0,0 +1,7 @@
+/**
+ * Returns true if the email value is either empty or matches a valid email format.
+ */
+export function isValidEmail(email: string): boolean {
+ if (!email.trim()) return true
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())
+}
diff --git a/src/utils/index.ts b/src/utils/index.ts
index 7d626c39..bc91b2c0 100644
--- a/src/utils/index.ts
+++ b/src/utils/index.ts
@@ -12,6 +12,7 @@ export * from './FormatAddress'
export * from './FormatNotes'
export * from './FormatPhone'
export * from './FormatTitle'
+export * from './ValidateEmail'
export * from './GetFieldPathsFromLoc'
export * from './GetLabelFromOptionalPdfFieldKey'
export * from './GetFormattedDate'