From 782708e1bfc3c26ae16ab990aa6d4ec947baa377 Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Fri, 26 Jun 2026 09:12:06 -0600 Subject: [PATCH 01/29] Add ContactEditPanel component for inline contact editing New edit panel for the Contact show page, matching the existing WellEditPanel pattern. Allows editing name, organization, role, and contact_type via a slide-in panel. Uses Shadcn Input and Select, EditPanel/EditPanelSection/EditPanelField layout, and an AlertDialog for discard-changes confirmation. Saves via PATCH /contact/{id} and invalidates the contact detail and list queries on success. --- .../ContactEdit/ContactEditPanel.tsx | 315 ++++++++++++++++++ 1 file changed, 315 insertions(+) create mode 100644 src/components/ContactEdit/ContactEditPanel.tsx diff --git a/src/components/ContactEdit/ContactEditPanel.tsx b/src/components/ContactEdit/ContactEditPanel.tsx new file mode 100644 index 00000000..e4c6d796 --- /dev/null +++ b/src/components/ContactEdit/ContactEditPanel.tsx @@ -0,0 +1,315 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { useUpdate, useNotification, useInvalidate } from '@refinedev/core' +import { Loader2 } from 'lucide-react' +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' + +interface ContactEditPanelProps { + contactId: string | number + contact: IContact | undefined + isLoading?: boolean + onClose: () => void +} + +interface DraftContact { + name: string + organization: string + role: string + contact_type: string +} + +function draftFromContact(contact: IContact | undefined): DraftContact { + return { + name: contact?.name ?? '', + organization: contact?.organization ?? '', + role: contact?.role ?? '', + contact_type: contact?.contact_type ?? '', + } +} + +function draftsAreEqual(a: DraftContact, b: DraftContact): boolean { + return ( + a.name === b.name && + a.organization === b.organization && + a.role === b.role && + a.contact_type === b.contact_type + ) +} + +export function ContactEditPanel({ + contactId, + contact, + isLoading = false, + onClose, +}: ContactEditPanelProps) { + const { open: notify } = useNotification() + const invalidate = useInvalidate() + const { mutateAsync: update, mutation } = useUpdate() + const isSaving = mutation.isPending + + const [draft, setDraft] = useState(() => + draftFromContact(contact) + ) + const [initial, setInitial] = useState(() => + draftFromContact(contact) + ) + const [discardDialogOpen, setDiscardDialogOpen] = useState(false) + const wasLoadingRef = useRef(true) + + const panelTitle = contact + ? `Edit: ${getContactDisplayName(contact)}` + : 'Edit' + + const { options: roleOptions, isLoading: roleLoading } = useLexicon({ + category: 'role', + }) + const { options: contactTypeOptions, isLoading: contactTypeLoading } = + useLexicon({ category: 'contact_type' }) + const isOptionsLoading = roleLoading || contactTypeLoading + + useEffect(() => { + wasLoadingRef.current = true + }, [contactId]) + + useEffect(() => { + if (isLoading) { + wasLoadingRef.current = true + return + } + + if (!wasLoadingRef.current) return + + const synced = draftFromContact(contact) + setDraft(synced) + setInitial(synced) + wasLoadingRef.current = false + }, [contact, isLoading, contactId]) + + const isDirty = useMemo( + () => !draftsAreEqual(draft, initial), + [draft, initial] + ) + + const setField = ( + key: K, + value: DraftContact[K] + ) => { + setDraft((prev) => ({ ...prev, [key]: value })) + } + + const handleSave = async () => { + if (!isDirty || isSaving) return + + const changes: Record = {} + if (draft.name !== initial.name) changes.name = draft.name || undefined + if (draft.organization !== initial.organization) + changes.organization = draft.organization || undefined + if (draft.role !== initial.role) changes.role = draft.role || undefined + if (draft.contact_type !== initial.contact_type) + changes.contact_type = draft.contact_type || undefined + + try { + await update({ + resource: 'contact', + dataProviderName: 'ocotillo', + id: contactId, + values: changes, + successNotification: false, + }) + await invalidate({ + resource: 'contact', + dataProviderName: 'ocotillo', + id: contactId, + invalidates: ['detail', 'list'], + }) + onClose() + } catch { + notify?.({ + type: 'error', + message: 'Could not save contact changes. Please try again.', + }) + } + } + + const handleRequestClose = () => { + if (isSaving) return + if (isDirty) { + setDiscardDialogOpen(true) + return + } + onClose() + } + + const handleDiscardChanges = () => { + onClose() + } + + return ( + <> + + + + + } + > + + {isLoading ? ( + <> +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + ) : ( + <> + + setField('name', e.target.value)} + disabled={isSaving} + className="h-8 text-sm" + placeholder="Contact name" + /> + + + setField('organization', e.target.value)} + disabled={isSaving} + className="h-8 text-sm" + placeholder="Organization" + /> + + + {isOptionsLoading ? ( + + ) : ( + + )} + + + {isOptionsLoading ? ( + + ) : ( + + )} + + + )} +
+
+ + + + + Discard unsaved changes? + + Changes you have not saved will be lost. + + + + Keep editing + + Discard + + + + + + ) +} From 4096c7e7859f740bb67c335c094e1b4606b7d11d Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Fri, 26 Jun 2026 09:12:47 -0600 Subject: [PATCH 02/29] Add edit panel to Contact show page Replaces the suppressed headerButtons with an Edit button that toggles a ContactEditPanel slide-in panel, matching the Wells show page pattern exactly. Uses useSidebarPanelSync so the sidebar collapses when the panel opens. Edit button is only shown to users with AMPEditor or AMPAdmin role. --- src/pages/ocotillo/contact/show.tsx | 140 ++++++++++++++++++---------- 1 file changed, 91 insertions(+), 49 deletions(-) diff --git a/src/pages/ocotillo/contact/show.tsx b/src/pages/ocotillo/contact/show.tsx index 2027b69d..4342ddb8 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,90 @@ export const ContactShow = () => { const contact = record + const { + isPanelOpen: isEditPanelOpen, + closePanel: closeEditPanel, + togglePanel: toggleEditPanel, + } = useSidebarPanelSync() + return ( - - {contact?.role ? ( - - ) : null} - {contact?.organization ? ( - - ) : null} - + + ) : 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 */} + + + + + - - - + + + ) } From 30a69ec9bba06a4eddcb229dd465859694a306ee Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Fri, 26 Jun 2026 09:13:18 -0600 Subject: [PATCH 03/29] Remove Create button from Contacts list page Creating contacts is handled elsewhere. Removes the + Create button and cleans up the unused useNavigation and Button imports. --- src/pages/ocotillo/contact/list.tsx | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/src/pages/ocotillo/contact/list.tsx b/src/pages/ocotillo/contact/list.tsx index b2890319..b3853738 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} onRowClick={(params) => captureEvent('contacts_row_clicked', { contact_id: params.id }) } From 4c382e1f4faf74c08eaeb784289fa13f49ccd917 Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Fri, 26 Jun 2026 09:13:43 -0600 Subject: [PATCH 04/29] Remove old edit/create routes from contact and well resource definitions Contact editing now happens in the inline ContactEditPanel on the show page, so the edit and create resource actions are no longer needed. Also removes the well edit resource action since WellEditPanel replaced the full-page edit route. --- src/resources/ocotillo.tsx | 3 --- 1 file changed, 3 deletions(-) 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', From 3fa0b8468d464e6cf22ec635d29b210d2369654c Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Fri, 26 Jun 2026 09:14:16 -0600 Subject: [PATCH 05/29] Remove old contact create/edit and well edit routes ContactEdit and ContactCreate pages are no longer reachable now that editing is done via the inline panel and creating contacts is not a supported flow from the UI. WellEdit full-page route is removed because WellEditPanel is the current standard editing interface for wells. --- src/routes/ocotillo.tsx | 6 ------ 1 file changed, 6 deletions(-) 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 = () => { } /> - } /> } /> From acdaf771e1da6fb52fbfbf5190cf718ce436445a Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Fri, 26 Jun 2026 09:14:48 -0600 Subject: [PATCH 06/29] Delete old contact create/edit pages and well full-page edit page These pages are no longer registered in routes or resources. Contact editing now happens in ContactEditPanel. Well editing uses WellEditPanel. The barrel index files are updated to remove the deleted exports. --- src/pages/ocotillo/contact/create.tsx | 87 ------------------------- src/pages/ocotillo/contact/edit.tsx | 94 --------------------------- src/pages/ocotillo/contact/index.tsx | 2 - src/pages/ocotillo/thing/edit.tsx | 21 ------ src/pages/ocotillo/thing/index.tsx | 1 - 5 files changed, 205 deletions(-) delete mode 100644 src/pages/ocotillo/contact/create.tsx delete mode 100644 src/pages/ocotillo/contact/edit.tsx delete mode 100644 src/pages/ocotillo/thing/edit.tsx 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/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' From c448c2cade6d00bc680dd7de06f7552c8dca0ecf Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Fri, 26 Jun 2026 09:16:59 -0600 Subject: [PATCH 07/29] Add PostHog edit funnel events to ContactEditPanel Fires edit_panel_opened on mount, edit_saved on successful patch (with fields_changed list), and edit_abandoned when the user discards changes (with had_changes flag). Events carry resource='contact' and contact_id so they can be filtered alongside well events in a shared funnel. --- .../ContactEdit/ContactEditPanel.tsx | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/components/ContactEdit/ContactEditPanel.tsx b/src/components/ContactEdit/ContactEditPanel.tsx index e4c6d796..1b9cc303 100644 --- a/src/components/ContactEdit/ContactEditPanel.tsx +++ b/src/components/ContactEdit/ContactEditPanel.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { useUpdate, useNotification, useInvalidate } from '@refinedev/core' import { Loader2 } from 'lucide-react' +import { captureEvent } from '@/analytics/posthog' import { Button, buttonVariants } from '@/components/ui/button' import { AlertDialog, @@ -93,6 +94,13 @@ export function ContactEditPanel({ useLexicon({ category: 'contact_type' }) const isOptionsLoading = roleLoading || contactTypeLoading + useEffect(() => { + captureEvent('edit_panel_opened', { + resource: 'contact', + contact_id: contactId, + }) + }, [contactId]) + useEffect(() => { wasLoadingRef.current = true }, [contactId]) @@ -148,6 +156,11 @@ export function ContactEditPanel({ id: contactId, invalidates: ['detail', 'list'], }) + captureEvent('edit_saved', { + resource: 'contact', + contact_id: contactId, + fields_changed: Object.keys(changes), + }) onClose() } catch { notify?.({ @@ -167,6 +180,11 @@ export function ContactEditPanel({ } const handleDiscardChanges = () => { + captureEvent('edit_abandoned', { + resource: 'contact', + contact_id: contactId, + had_changes: isDirty, + }) onClose() } From fb2aac01f8c65d91d37db7116f6880683a7cceab Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Fri, 26 Jun 2026 09:17:07 -0600 Subject: [PATCH 08/29] Add PostHog edit funnel events to WellEditPanel Fires edit_panel_opened on mount, edit_saved on successful group save, and edit_abandoned when the user discards changes. Matches the contact edit events shape so both resources appear in the same funnel using a resource property filter. --- src/components/WellEdit/WellEditPanel.tsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) 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() } From 59194e122951f978dfe86d33c8b89a35648e51a1 Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Fri, 26 Jun 2026 09:23:02 -0600 Subject: [PATCH 09/29] Add @testing-library/jest-dom to global test setup Registers DOM matchers like toBeDisabled and toHaveTextContent globally so they are available across all test files. --- src/test/setup.ts | 1 + 1 file changed, 1 insertion(+) 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' From 8fb0d7db8bf353de0f107b0f8e84b932207dbf7d Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Fri, 26 Jun 2026 09:23:13 -0600 Subject: [PATCH 10/29] Add unit tests for ContactEditPanel 18 tests covering: PostHog funnel events (edit_panel_opened, edit_saved, edit_abandoned), panel title display, field pre-population from the contact prop, save button enabled/disabled state, useUpdate called with only changed fields, cache invalidation after save, and the full close/discard dialog flow including keep-editing cancellation. --- src/test/components/ContactEditPanel.test.tsx | 416 ++++++++++++++++++ 1 file changed, 416 insertions(+) create mode 100644 src/test/components/ContactEditPanel.test.tsx diff --git a/src/test/components/ContactEditPanel.test.tsx b/src/test/components/ContactEditPanel.test.tsx new file mode 100644 index 00000000..9d6cabec --- /dev/null +++ b/src/test/components/ContactEditPanel.test.tsx @@ -0,0 +1,416 @@ +// @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 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 }, + }), + useInvalidate: () => invalidateMock, + useNotification: () => ({ open: notifyMock }), +})) + +vi.mock('@/hooks', () => ({ + useLexicon: ({ category }: { category: string }) => ({ + options: + category === 'role' + ? [ + { value: 'Owner', label: 'Owner' }, + { value: 'Manager', label: 'Manager' }, + ] + : [ + { value: 'Primary', label: 'Primary' }, + { value: 'Secondary', label: 'Secondary' }, + ], + 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' + +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 renderPanel = (contact: IContact = SAMPLE_CONTACT) => + render( + + ) + +describe('ContactEditPanel', () => { + beforeEach(() => { + captureEventMock.mockClear() + updateMutateAsyncMock.mockClear() + invalidateMock.mockClear() + notifyMock.mockClear() + onCloseMock.mockClear() + updateMutateAsyncMock.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 changed fields after a successful save', 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: ['name'], + }) + ) + }) + }) + + 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 orgInput = screen.getByDisplayValue('NMBGMR') + await user.clear(orgInput) + await user.type(orgInput, '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', () => { + it('sends only the changed field to useUpdate', async () => { + const user = userEvent.setup() + renderPanel() + + const orgInput = screen.getByDisplayValue('NMBGMR') + await user.clear(orgInput) + await user.type(orgInput, '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() + }) + }) +}) From 894126efe0de02f2ac3604e4019f82daf438b090 Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Fri, 26 Jun 2026 09:23:19 -0600 Subject: [PATCH 11/29] Add unit tests for WellEditPanel 17 tests covering: PostHog funnel events (edit_panel_opened, edit_saved, edit_abandoned), panel title display, assigned groups rendered as chips, empty state message, save button enabled/disabled state, delete mutation called for removed groups, well details invalidated after save, and the full close/discard dialog flow including keep-editing cancellation. --- src/test/components/WellEditPanel.test.tsx | 383 +++++++++++++++++++++ 1 file changed, 383 insertions(+) create mode 100644 src/test/components/WellEditPanel.test.tsx diff --git a/src/test/components/WellEditPanel.test.tsx b/src/test/components/WellEditPanel.test.tsx new file mode 100644 index 00000000..8656163d --- /dev/null +++ b/src/test/components/WellEditPanel.test.tsx @@ -0,0 +1,383 @@ +// @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' } +const GROUP_BETA: IGroup = { id: 2, name: 'Project Beta', group_type: null } + +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() + }) + }) +}) From 4c53052653c9335d80f765a56abfd6886bde3c50 Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Fri, 26 Jun 2026 09:35:36 -0600 Subject: [PATCH 12/29] Expand ContactEditPanel to edit emails, phones, and addresses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added three new collapsible sections to the contact editing panel. Each section lets users add new items, edit existing ones, and delete them. All sub-resource changes (POST/PATCH/DELETE) are batched and run in parallel on save. Updated tests to cover the new sections — 40 tests total, all passing. --- .../ContactEdit/ContactEditPanel.tsx | 835 ++++++++++++++++-- 1 file changed, 784 insertions(+), 51 deletions(-) diff --git a/src/components/ContactEdit/ContactEditPanel.tsx b/src/components/ContactEdit/ContactEditPanel.tsx index 1b9cc303..528af332 100644 --- a/src/components/ContactEdit/ContactEditPanel.tsx +++ b/src/components/ContactEdit/ContactEditPanel.tsx @@ -1,6 +1,11 @@ import { useEffect, useMemo, useRef, useState } from 'react' -import { useUpdate, useNotification, useInvalidate } from '@refinedev/core' -import { Loader2 } from 'lucide-react' +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 { @@ -31,6 +36,8 @@ import { useLexicon } from '@/hooks' import { getContactDisplayName } from '@/utils/contactDisplayName' import type { IContact } from '@/interfaces/ocotillo' +// ─── Types ──────────────────────────────────────────────────────────────────── + interface ContactEditPanelProps { contactId: string | number contact: IContact | undefined @@ -38,14 +45,47 @@ interface ContactEditPanelProps { onClose: () => void } -interface DraftContact { +interface ContactDetailsDraft { name: string organization: string role: string contact_type: string } -function draftFromContact(contact: IContact | undefined): DraftContact { +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 ?? '', @@ -54,7 +94,42 @@ function draftFromContact(contact: IContact | undefined): DraftContact { } } -function draftsAreEqual(a: DraftContact, b: DraftContact): boolean { +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: 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 && @@ -63,6 +138,250 @@ function draftsAreEqual(a: DraftContact, b: DraftContact): boolean { ) } +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 +} + +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 }[] +}) { + return ( +
+ + onChange({ ...email, email: e.target.value })} + disabled={disabled} + className="h-8 text-sm" + placeholder="name@example.com" + /> + + + + + +
+ ) +} + +function PhoneRow({ + phone, + onChange, + onDelete, + disabled, + typeOptions, +}: { + phone: PhoneDraft + onChange: (updated: PhoneDraft) => void + onDelete: () => void + disabled: boolean + typeOptions: { value: string; label: string }[] +}) { + return ( +
+ + onChange({ ...phone, phone_number: e.target.value })} + disabled={disabled} + className="h-8 text-sm" + placeholder="+1 (505) 555-0100" + /> + + + + + +
+ ) +} + +function AddressBlock({ + address, + onChange, + onDelete, + disabled, + typeOptions, +}: { + address: AddressDraft + onChange: (updated: AddressDraft) => void + onDelete: () => void + disabled: boolean + typeOptions: { value: string; label: string }[] +}) { + return ( +
+
+ + +
+ onChange({ ...address, address_line_1: e.target.value })} + disabled={disabled} + className="h-8 text-sm" + placeholder="Address line 1" + aria-label="Address line 1" + /> + onChange({ ...address, address_line_2: e.target.value })} + disabled={disabled} + className="h-8 text-sm" + placeholder="Address line 2 (optional)" + aria-label="Address line 2" + /> +
+ onChange({ ...address, city: e.target.value })} + disabled={disabled} + className="h-8 text-sm" + placeholder="City" + aria-label="City" + /> + onChange({ ...address, state: e.target.value })} + disabled={disabled} + className="h-8 text-sm" + placeholder="State" + aria-label="State" + /> + onChange({ ...address, postal_code: e.target.value })} + disabled={disabled} + className="h-8 text-sm" + placeholder="Postal code" + aria-label="Postal code" + /> + onChange({ ...address, country: e.target.value })} + disabled={disabled} + className="h-8 text-sm" + placeholder="Country" + aria-label="Country" + /> +
+
+ ) +} + +// ─── Main component ─────────────────────────────────────────────────────────── + export function ContactEditPanel({ contactId, contact, @@ -71,29 +390,75 @@ export function ContactEditPanel({ }: ContactEditPanelProps) { const { open: notify } = useNotification() const invalidate = useInvalidate() - const { mutateAsync: update, mutation } = useUpdate() - const isSaving = mutation.isPending + const { mutateAsync: update } = useUpdate() + const { mutateAsync: mutate } = useCustomMutation() - const [draft, setDraft] = useState(() => - draftFromContact(contact) - ) - const [initial, setInitial] = useState(() => - draftFromContact(contact) - ) + const [isSaving, setIsSaving] = useState(false) const [discardDialogOpen, setDiscardDialogOpen] = useState(false) const wasLoadingRef = useRef(true) - const panelTitle = contact - ? `Edit: ${getContactDisplayName(contact)}` - : 'Edit' + // ── 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 isOptionsLoading = roleLoading || contactTypeLoading + 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 isOptionsLoading = + roleLoading || + contactTypeLoading || + emailTypeLoading || + phoneTypeLoading || + addressTypeLoading + + const panelTitle = contact + ? `Edit: ${getContactDisplayName(contact)}` + : 'Edit' + // ── Sync state when contact loads ───────────────────────────────────────── useEffect(() => { captureEvent('edit_panel_opened', { resource: 'contact', @@ -110,63 +475,290 @@ export function ContactEditPanel({ wasLoadingRef.current = true return } - if (!wasLoadingRef.current) return - const synced = draftFromContact(contact) - setDraft(synced) - setInitial(synced) + 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]) - const isDirty = useMemo( - () => !draftsAreEqual(draft, initial), - [draft, initial] - ) + // ── isDirty ─────────────────────────────────────────────────────────────── + 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 setField = ( - key: K, - value: DraftContact[K] - ) => { - setDraft((prev) => ({ ...prev, [key]: value })) + 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 changes: Record = {} - if (draft.name !== initial.name) changes.name = draft.name || undefined - if (draft.organization !== initial.organization) - changes.organization = draft.organization || undefined - if (draft.role !== initial.role) changes.role = draft.role || undefined - if (draft.contact_type !== initial.contact_type) - changes.contact_type = draft.contact_type || undefined + setIsSaving(true) try { - await update({ - resource: 'contact', - dataProviderName: 'ocotillo', - id: contactId, - values: changes, - successNotification: false, - }) + 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 || undefined + 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: 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: 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: Object.keys(changes), + fields_changed: changedSections, }) onClose() } catch { notify?.({ type: 'error', - message: 'Could not save contact changes. Please try again.', + message: 'Could not save changes. Please try again.', }) + } finally { + setIsSaving(false) } } @@ -188,6 +780,8 @@ export function ContactEditPanel({ onClose() } + // ── Render ──────────────────────────────────────────────────────────────── + return ( <> } > + {/* Contact Details */} {isLoading ? ( <> @@ -245,7 +840,9 @@ export function ContactEditPanel({ setField('name', e.target.value)} + onChange={(e) => + setDraft((prev) => ({ ...prev, name: e.target.value })) + } disabled={isSaving} className="h-8 text-sm" placeholder="Contact name" @@ -254,7 +851,12 @@ export function ContactEditPanel({ setField('organization', e.target.value)} + onChange={(e) => + setDraft((prev) => ({ + ...prev, + organization: e.target.value, + })) + } disabled={isSaving} className="h-8 text-sm" placeholder="Organization" @@ -266,7 +868,9 @@ export function ContactEditPanel({ ) : ( setField('contact_type', v)} + onValueChange={(v) => + setDraft((prev) => ({ ...prev, contact_type: v })) + } disabled={isSaving} > @@ -307,6 +913,133 @@ export function ContactEditPanel({ )} + + {/* Emails */} + + {draftEmails.map((email) => ( + + setDraftEmails((prev) => + prev.map((e) => (e.draftId === updated.draftId ? updated : e)) + ) + } + onDelete={() => handleDeleteEmail(email)} + disabled={isSaving} + typeOptions={emailTypeOptions} + /> + ))} +
+ +
+
+ + {/* Phones */} + + {draftPhones.map((phone) => ( + + setDraftPhones((prev) => + prev.map((p) => (p.draftId === updated.draftId ? updated : p)) + ) + } + onDelete={() => handleDeletePhone(phone)} + disabled={isSaving} + typeOptions={phoneTypeOptions} + /> + ))} +
+ +
+
+ + {/* Addresses */} + + {draftAddresses.map((address) => ( + + setDraftAddresses((prev) => + prev.map((a) => + a.draftId === updated.draftId ? updated : a + ) + ) + } + onDelete={() => handleDeleteAddress(address)} + disabled={isSaving} + typeOptions={addressTypeOptions} + /> + ))} +
+ +
+
From 03543daaaa9aeb93e3ae3659dcca9a4945b3bf18 Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Fri, 26 Jun 2026 09:35:42 -0600 Subject: [PATCH 13/29] Add tests for email, phone, and address editing in ContactEditPanel Expanded the test suite from 18 to 40 tests. New tests cover rendering existing sub-resources, adding new rows, deleting existing items, and verifying the correct POST/PATCH/DELETE mutations are sent on save. --- src/test/components/ContactEditPanel.test.tsx | 408 +++++++++++++++++- 1 file changed, 392 insertions(+), 16 deletions(-) diff --git a/src/test/components/ContactEditPanel.test.tsx b/src/test/components/ContactEditPanel.test.tsx index 9d6cabec..e6d92cf2 100644 --- a/src/test/components/ContactEditPanel.test.tsx +++ b/src/test/components/ContactEditPanel.test.tsx @@ -6,6 +6,7 @@ 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() @@ -19,24 +20,40 @@ vi.mock('@refinedev/core', () => ({ mutateAsync: updateMutateAsyncMock, mutation: { isPending: false }, }), + useCustomMutation: () => ({ + mutateAsync: customMutateMock, + mutation: { isPending: false }, + }), useInvalidate: () => invalidateMock, useNotification: () => ({ open: notifyMock }), })) vi.mock('@/hooks', () => ({ - useLexicon: ({ category }: { category: string }) => ({ - options: - category === 'role' - ? [ - { value: 'Owner', label: 'Owner' }, - { value: 'Manager', label: 'Manager' }, - ] - : [ - { value: 'Primary', label: 'Primary' }, - { value: 'Secondary', label: 'Secondary' }, - ], - isLoading: false, - }), + 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' }, + ], + } + return { options: options[category] ?? [], isLoading: false } + }, })) vi.mock('@/components/editing', () => ({ @@ -153,6 +170,8 @@ vi.mock('@/components/ui/skeleton', () => ({ import { ContactEditPanel } from '@/components/ContactEdit/ContactEditPanel' import type { IContact } from '@/interfaces/ocotillo' +// ─── Test fixtures ──────────────────────────────────────────────────────────── + const SAMPLE_CONTACT: IContact = { id: 7, name: 'Rachel Benjamin', @@ -163,6 +182,52 @@ const SAMPLE_CONTACT: IContact = { 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) }) @@ -193,7 +262,7 @@ describe('ContactEditPanel', () => { }) }) - it('fires edit_saved with changed fields after a successful save', async () => { + it('fires edit_saved with contact_details section after saving basic fields', async () => { const user = userEvent.setup() renderPanel() @@ -208,7 +277,26 @@ describe('ContactEditPanel', () => { expect.objectContaining({ resource: 'contact', contact_id: SAMPLE_CONTACT.id, - fields_changed: ['name'], + 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']), }) ) }) @@ -311,7 +399,7 @@ describe('ContactEditPanel', () => { }) }) - describe('saving', () => { + describe('saving contact details', () => { it('sends only the changed field to useUpdate', async () => { const user = userEvent.setup() renderPanel() @@ -413,4 +501,292 @@ describe('ContactEditPanel', () => { 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('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', () => { + renderPanel(CONTACT_WITH_PHONE) + expect(screen.getByDisplayValue('5055550001')).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 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('+1 (505) 555-0100'), + '5055559999' + ) + expect(screen.getByRole('button', { name: 'Save' })).not.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 5055550001/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 5055550001/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 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('+1 (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: '5055559999', + }), + }) + ) + }) + }) + }) + + 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', + }), + }) + ) + }) + }) + }) }) From aba98212a87878af9bd0dfebcaae38262b816306 Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Fri, 26 Jun 2026 09:38:03 -0600 Subject: [PATCH 14/29] Move phone numbers section above email addresses in contact edit panel --- .../ContactEdit/ContactEditPanel.tsx | 56 +++++++++---------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/src/components/ContactEdit/ContactEditPanel.tsx b/src/components/ContactEdit/ContactEditPanel.tsx index 528af332..2f40a27c 100644 --- a/src/components/ContactEdit/ContactEditPanel.tsx +++ b/src/components/ContactEdit/ContactEditPanel.tsx @@ -914,20 +914,20 @@ export function ContactEditPanel({ )} - {/* Emails */} - - {draftEmails.map((email) => ( - + {draftPhones.map((phone) => ( + - setDraftEmails((prev) => - prev.map((e) => (e.draftId === updated.draftId ? updated : e)) + setDraftPhones((prev) => + prev.map((p) => (p.draftId === updated.draftId ? updated : p)) ) } - onDelete={() => handleDeleteEmail(email)} + onDelete={() => handleDeletePhone(phone)} disabled={isSaving} - typeOptions={emailTypeOptions} + typeOptions={phoneTypeOptions} /> ))}
@@ -936,12 +936,12 @@ export function ContactEditPanel({ variant="outline" size="sm" onClick={() => - setDraftEmails((prev) => [ + setDraftPhones((prev) => [ ...prev, { draftId: generateDraftId(), - email: '', - email_type: emailTypeOptions[0]?.value ?? 'Primary', + phone_number: '', + phone_type: phoneTypeOptions[0]?.value ?? 'Primary', }, ]) } @@ -949,25 +949,25 @@ export function ContactEditPanel({ className="w-full" > - Add email + Add phone
- {/* Phones */} - - {draftPhones.map((phone) => ( - + {draftEmails.map((email) => ( + - setDraftPhones((prev) => - prev.map((p) => (p.draftId === updated.draftId ? updated : p)) + setDraftEmails((prev) => + prev.map((e) => (e.draftId === updated.draftId ? updated : e)) ) } - onDelete={() => handleDeletePhone(phone)} + onDelete={() => handleDeleteEmail(email)} disabled={isSaving} - typeOptions={phoneTypeOptions} + typeOptions={emailTypeOptions} /> ))}
@@ -976,12 +976,12 @@ export function ContactEditPanel({ variant="outline" size="sm" onClick={() => - setDraftPhones((prev) => [ + setDraftEmails((prev) => [ ...prev, { draftId: generateDraftId(), - phone_number: '', - phone_type: phoneTypeOptions[0]?.value ?? 'Primary', + email: '', + email_type: emailTypeOptions[0]?.value ?? 'Primary', }, ]) } @@ -989,7 +989,7 @@ export function ContactEditPanel({ className="w-full" > - Add phone + Add email
From fcb439847a1716222dc8954b6ba55a4dc62ee3ff Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Fri, 26 Jun 2026 09:39:29 -0600 Subject: [PATCH 15/29] Fix trash icon alignment in email and phone rows so it sits on the same line as the input and type dropdown --- .../ContactEdit/ContactEditPanel.tsx | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/src/components/ContactEdit/ContactEditPanel.tsx b/src/components/ContactEdit/ContactEditPanel.tsx index 2f40a27c..089b1019 100644 --- a/src/components/ContactEdit/ContactEditPanel.tsx +++ b/src/components/ContactEdit/ContactEditPanel.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useRef, useState } from 'react' +import { Label } from '@/components/ui/label' import { useUpdate, useNotification, @@ -186,8 +187,9 @@ function EmailRow({ typeOptions: { value: string; label: string }[] }) { return ( -
- +
+
+ - - +
+
+ - +
- + {invalid && ( +

Enter a valid email address.

+ )}
) } @@ -244,47 +285,61 @@ function PhoneRow({ disabled: boolean typeOptions: { value: string; label: string }[] }) { + const invalid = phone.phone_number.trim() !== '' && !isValidPhone(phone.phone_number) + return ( -
-
- - onChange({ ...phone, phone_number: e.target.value })} - disabled={disabled} - className="h-8 text-sm" - placeholder="+1 (505) 555-0100" - /> -
-
- - { + const formatted = formatPhoneDigits(e.target.value) + onChange({ ...phone, phone_number: formatted }) + }} + disabled={disabled} + className={`h-8 text-sm ${invalid ? 'border-destructive focus-visible:ring-destructive' : ''}`} + placeholder="(505) 555-0100" + aria-invalid={invalid} + /> +
+
+ + +
+
-
) } @@ -558,6 +613,27 @@ export function ContactEditPanel({ 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 { @@ -658,7 +734,7 @@ export function ContactEditPanel({ method: 'post', values: { contact_id: Number(contactId), - phone_number: phone.phone_number, + phone_number: displayToE164(phone.phone_number), phone_type: phone.phone_type, }, dataProviderName: 'ocotillo', @@ -673,7 +749,7 @@ export function ContactEditPanel({ url: `contact/phone/${phone.id}`, method: 'patch', values: { - phone_number: phone.phone_number, + phone_number: displayToE164(phone.phone_number), phone_type: phone.phone_type, }, dataProviderName: 'ocotillo', diff --git a/src/test/components/ContactEditPanel.test.tsx b/src/test/components/ContactEditPanel.test.tsx index e6d92cf2..e1bfd0b1 100644 --- a/src/test/components/ContactEditPanel.test.tsx +++ b/src/test/components/ContactEditPanel.test.tsx @@ -612,9 +612,10 @@ describe('ContactEditPanel', () => { }) describe('phone section', () => { - it('renders existing phone numbers from the contact', () => { + it('renders existing phone numbers from the contact in display format', () => { renderPanel(CONTACT_WITH_PHONE) - expect(screen.getByDisplayValue('5055550001')).toBeTruthy() + // 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 () => { @@ -624,12 +625,12 @@ describe('ContactEditPanel', () => { expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled() }) - it('enables Save after typing into a new phone row', async () => { + 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('+1 (505) 555-0100'), + screen.getByPlaceholderText('(505) 555-0100'), '5055559999' ) expect(screen.getByRole('button', { name: 'Save' })).not.toBeDisabled() @@ -639,7 +640,7 @@ describe('ContactEditPanel', () => { const user = userEvent.setup() renderPanel(CONTACT_WITH_PHONE) await user.click( - screen.getByRole('button', { name: /Remove phone 5055550001/i }) + screen.getByRole('button', { name: /Remove phone \(505\) 555-0001/i }) ) expect(screen.getByRole('button', { name: 'Save' })).not.toBeDisabled() }) @@ -649,7 +650,7 @@ describe('ContactEditPanel', () => { renderPanel(CONTACT_WITH_PHONE) await user.click( - screen.getByRole('button', { name: /Remove phone 5055550001/i }) + screen.getByRole('button', { name: /Remove phone \(505\) 555-0001/i }) ) await user.click(screen.getByRole('button', { name: 'Save' })) @@ -663,13 +664,13 @@ describe('ContactEditPanel', () => { }) }) - it('sends POST mutation for a new phone on save', async () => { + 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('+1 (505) 555-0100'), + screen.getByPlaceholderText('(505) 555-0100'), '5055559999' ) await user.click(screen.getByRole('button', { name: 'Save' })) @@ -681,7 +682,7 @@ describe('ContactEditPanel', () => { method: 'post', values: expect.objectContaining({ contact_id: SAMPLE_CONTACT.id, - phone_number: '5055559999', + phone_number: '+15055559999', }), }) ) From 19a57313f69282a713774a884f199f8747ed4c3d Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Fri, 26 Jun 2026 10:07:22 -0600 Subject: [PATCH 17/29] Improve validation UX in contact edit panel email and phone rows Error messages now always occupy their line in the layout (invisible when not erroring) so nothing jumps when a message appears. Each error element has a proper id, aria-describedby on its input, and role=alert so screen readers announce it. Save is disabled whenever any email or phone value is invalid, so users know to fix the field before trying to save. --- .../ContactEdit/ContactEditPanel.tsx | 37 ++++++++++++++++--- 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/src/components/ContactEdit/ContactEditPanel.tsx b/src/components/ContactEdit/ContactEditPanel.tsx index acf0c899..8dd66293 100644 --- a/src/components/ContactEdit/ContactEditPanel.tsx +++ b/src/components/ContactEdit/ContactEditPanel.tsx @@ -220,9 +220,10 @@ function EmailRow({ typeOptions: { value: string; label: string }[] }) { const invalid = !isValidEmail(email.email) + const errorId = `email-error-${email.draftId}` return ( -
+
@@ -234,6 +235,7 @@ function EmailRow({ className={`h-8 text-sm ${invalid ? 'border-destructive focus-visible:ring-destructive' : ''}`} placeholder="name@example.com" aria-invalid={invalid} + aria-describedby={errorId} />
@@ -265,9 +267,15 @@ function EmailRow({
- {invalid && ( -

Enter a valid email address.

- )} +

+ Enter a valid email address. +

) } @@ -286,9 +294,10 @@ function PhoneRow({ typeOptions: { value: string; label: string }[] }) { const invalid = phone.phone_number.trim() !== '' && !isValidPhone(phone.phone_number) + const errorId = `phone-error-${phone.draftId}` return ( -
+
@@ -309,6 +318,7 @@ function PhoneRow({ className={`h-8 text-sm ${invalid ? 'border-destructive focus-visible:ring-destructive' : ''}`} placeholder="(505) 555-0100" aria-invalid={invalid} + aria-describedby={errorId} />
@@ -340,6 +350,15 @@ function PhoneRow({
+

+ Enter a 10-digit US phone number. +

) } @@ -560,6 +579,12 @@ export function ContactEditPanel({ }, [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 @@ -881,7 +906,7 @@ export function ContactEditPanel({
@@ -269,10 +272,10 @@ function EmailRow({

Enter a valid email address.

@@ -293,7 +296,9 @@ function PhoneRow({ 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 ( @@ -314,10 +319,11 @@ function PhoneRow({ const formatted = formatPhoneDigits(e.target.value) onChange({ ...phone, phone_number: formatted }) }} + onBlur={() => setTouched(true)} disabled={disabled} - className={`h-8 text-sm ${invalid ? 'border-destructive focus-visible:ring-destructive' : ''}`} + className={`h-8 text-sm ${showError ? 'border-destructive focus-visible:ring-destructive' : ''}`} placeholder="(505) 555-0100" - aria-invalid={invalid} + aria-invalid={showError} aria-describedby={errorId} />
@@ -352,10 +358,10 @@ function PhoneRow({

Enter a 10-digit US phone number.

From 8707d5a37168a8a4cc03c135d291bed0346569d3 Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Fri, 26 Jun 2026 10:22:58 -0600 Subject: [PATCH 19/29] Reorder contact details fields: Contact Type, Role, Name, Organization --- .../ContactEdit/ContactEditPanel.tsx | 70 +++++++++---------- 1 file changed, 35 insertions(+), 35 deletions(-) diff --git a/src/components/ContactEdit/ContactEditPanel.tsx b/src/components/ContactEdit/ContactEditPanel.tsx index 6d22f452..af9fabde 100644 --- a/src/components/ContactEdit/ContactEditPanel.tsx +++ b/src/components/ContactEdit/ContactEditPanel.tsx @@ -949,47 +949,22 @@ export function ContactEditPanel({ ) : ( <> - - - setDraft((prev) => ({ ...prev, name: e.target.value })) - } - disabled={isSaving} - className="h-8 text-sm" - placeholder="Contact name" - /> - - - - setDraft((prev) => ({ - ...prev, - organization: e.target.value, - })) - } - disabled={isSaving} - className="h-8 text-sm" - placeholder="Organization" - /> - - + {isOptionsLoading ? ( ) : ( )} - + {isOptionsLoading ? ( ) : ( )} + + + setDraft((prev) => ({ ...prev, name: e.target.value })) + } + disabled={isSaving} + className="h-8 text-sm" + placeholder="Contact name" + /> + + + + setDraft((prev) => ({ + ...prev, + organization: e.target.value, + })) + } + disabled={isSaving} + className="h-8 text-sm" + placeholder="Organization" + /> + )} From f175535d32d6a998fc2237be4e9e744203ec6e3a Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Fri, 26 Jun 2026 10:23:17 -0600 Subject: [PATCH 20/29] Update organization field placeholder text --- src/components/ContactEdit/ContactEditPanel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ContactEdit/ContactEditPanel.tsx b/src/components/ContactEdit/ContactEditPanel.tsx index af9fabde..52ea7bc5 100644 --- a/src/components/ContactEdit/ContactEditPanel.tsx +++ b/src/components/ContactEdit/ContactEditPanel.tsx @@ -1019,7 +1019,7 @@ export function ContactEditPanel({ } disabled={isSaving} className="h-8 text-sm" - placeholder="Organization" + placeholder="Organization or Company" /> From 37884d5c3c89bb2d21b968eaca821d240c743319 Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Fri, 26 Jun 2026 10:24:18 -0600 Subject: [PATCH 21/29] Replace state text input with US state dropdown, defaulting to NM --- .../ContactEdit/ContactEditPanel.tsx | 76 +++++++++++++++++-- 1 file changed, 70 insertions(+), 6 deletions(-) diff --git a/src/components/ContactEdit/ContactEditPanel.tsx b/src/components/ContactEdit/ContactEditPanel.tsx index 52ea7bc5..9c922ecc 100644 --- a/src/components/ContactEdit/ContactEditPanel.tsx +++ b/src/components/ContactEdit/ContactEditPanel.tsx @@ -153,6 +153,62 @@ function isPhoneModified(draft: PhoneDraft, initials: PhoneDraft[]): boolean { 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 { @@ -436,14 +492,22 @@ function AddressBlock({ placeholder="City" aria-label="City" /> - onChange({ ...address, state: e.target.value })} + onValueChange={(v) => onChange({ ...address, state: v })} disabled={disabled} - className="h-8 text-sm" - placeholder="State" - aria-label="State" - /> + > + + + + + {US_STATES.map((s) => ( + + {s.label} + + ))} + + onChange({ ...address, postal_code: e.target.value })} From ce59466e9e2e0c80c4563cc41f7f77f9b3dacd3b Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Fri, 26 Jun 2026 10:31:15 -0600 Subject: [PATCH 22/29] Fix useLexicon truncating options for large categories by setting pageSize 500 Lexicon categories like 'role' have 20+ terms. The default page size of 10 silently cut off options like 'Owner' (11th alphabetically), causing dropdowns to show blank for valid stored values. --- src/hooks/useLexicon.ts | 1 + 1 file changed, 1 insertion(+) 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 From 279388b3ba48d8c9c9a5ee35238c79922d9ebd69 Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Fri, 26 Jun 2026 11:19:40 -0600 Subject: [PATCH 23/29] Fix edit panel border not extending to bottom when content overflows Added min-h-0 and overflow-y-auto to the content area so the panel scrolls internally instead of pushing the page. This keeps the left border at full height regardless of content length. --- src/components/editing/EditPanel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 ? (
From c01083a6b323bc99f4947964422eb9d784f5c35e Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Fri, 26 Jun 2026 11:19:49 -0600 Subject: [PATCH 24/29] Replace organization free-text input with lexicon-backed select Organization is a foreign key to lexicon_term in the API, so free text caused 409 Conflict errors on save. Replaced with a Select populated by useLexicon({ category: 'organization' }), with a No organization option at the top for clearing the field. Also adds an onSaved callback prop so the show page can trigger a refetch after a successful save, fixing stale data after the panel closes. --- .../ContactEdit/ContactEditPanel.tsx | 128 +++++++++++------- 1 file changed, 79 insertions(+), 49 deletions(-) diff --git a/src/components/ContactEdit/ContactEditPanel.tsx b/src/components/ContactEdit/ContactEditPanel.tsx index 9c922ecc..ca27999d 100644 --- a/src/components/ContactEdit/ContactEditPanel.tsx +++ b/src/components/ContactEdit/ContactEditPanel.tsx @@ -44,15 +44,18 @@ interface ContactEditPanelProps { contact: IContact | undefined isLoading?: boolean onClose: () => void + onSaved?: () => void } interface ContactDetailsDraft { name: string - organization: string + organization: string | null role: string contact_type: string } +const ORG_NONE = '__none__' + interface EmailDraft { draftId: string id?: number @@ -89,7 +92,7 @@ function generateDraftId() { function initContactDraft(contact: IContact | undefined): ContactDetailsDraft { return { name: contact?.name ?? '', - organization: contact?.organization ?? '', + organization: contact?.organization ?? null, role: contact?.role ?? '', contact_type: contact?.contact_type ?? '', } @@ -281,7 +284,7 @@ function EmailRow({ const errorId = `email-error-${email.draftId}` return ( -
+
@@ -331,7 +334,7 @@ function EmailRow({ role={showError ? 'alert' : undefined} aria-live="polite" aria-hidden={!showError || undefined} - className={`min-h-4 text-xs text-destructive ${showError ? 'visible' : 'invisible'}`} + className={`min-h-3 text-xs text-destructive ${showError ? 'visible' : 'invisible'}`} > Enter a valid email address.

@@ -358,7 +361,7 @@ function PhoneRow({ const errorId = `phone-error-${phone.draftId}` return ( -
+
@@ -417,7 +420,7 @@ function PhoneRow({ role={showError ? 'alert' : undefined} aria-live="polite" aria-hidden={!showError || undefined} - className={`min-h-4 text-xs text-destructive ${showError ? 'visible' : 'invisible'}`} + className={`min-h-3 text-xs text-destructive ${showError ? 'visible' : 'invisible'}`} > Enter a 10-digit US phone number.

@@ -536,6 +539,7 @@ export function ContactEditPanel({ contact, isLoading = false, onClose, + onSaved, }: ContactEditPanelProps) { const { open: notify } = useNotification() const invalidate = useInvalidate() @@ -595,13 +599,16 @@ export function ContactEditPanel({ }) const { options: addressTypeOptions, isLoading: addressTypeLoading } = useLexicon({ category: 'address_type' }) + const { options: organizationOptions, isLoading: organizationLoading } = + useLexicon({ category: 'organization' }) const isOptionsLoading = roleLoading || contactTypeLoading || emailTypeLoading || phoneTypeLoading || - addressTypeLoading + addressTypeLoading || + organizationLoading const panelTitle = contact ? `Edit: ${getContactDisplayName(contact)}` @@ -737,10 +744,10 @@ export function ContactEditPanel({ // ── Contact details ────────────────────────────────────────────────── if (!contactDraftsEqual(draft, initial)) { - const changes: Record = {} + const changes: Record = {} if (draft.name !== initial.name) changes.name = draft.name || undefined if (draft.organization !== initial.organization) - changes.organization = draft.organization || undefined + 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 @@ -927,6 +934,7 @@ export function ContactEditPanel({ contact_id: contactId, fields_changed: changedSections, }) + onSaved?.() onClose() } catch { notify?.({ @@ -1073,18 +1081,36 @@ export function ContactEditPanel({ /> - - setDraft((prev) => ({ - ...prev, - organization: e.target.value, - })) - } - disabled={isSaving} - className="h-8 text-sm" - placeholder="Organization or Company" - /> + {isOptionsLoading ? ( + + ) : ( + + )} )} @@ -1092,20 +1118,22 @@ export function ContactEditPanel({ {/* Phones */} - {draftPhones.map((phone) => ( - - setDraftPhones((prev) => - prev.map((p) => (p.draftId === updated.draftId ? updated : p)) - ) - } - onDelete={() => handleDeletePhone(phone)} - disabled={isSaving} - typeOptions={phoneTypeOptions} - /> - ))} +
+ {draftPhones.map((phone) => ( + + setDraftPhones((prev) => + prev.map((p) => (p.draftId === updated.draftId ? updated : p)) + ) + } + onDelete={() => handleDeletePhone(phone)} + disabled={isSaving} + typeOptions={phoneTypeOptions} + /> + ))} +