diff --git a/feature_files/contact-display-name.md b/feature_files/contact-display-name.md new file mode 100644 index 00000000..338e280c --- /dev/null +++ b/feature_files/contact-display-name.md @@ -0,0 +1,120 @@ +--- +id: contact-display-name +title: Contact Display Name +status: partial +created: 2026-05-01 +updated: 2026-05-01 +related_files: + - src/utils/contactDisplayName.ts + - src/components/WellShow/Contacts.tsx + - src/pages/ocotillo/contact/list.tsx + - src/pages/ocotillo/contact/show.tsx + - src/pages/ocotillo/thing/list.tsx + - api/search.py # OcotilloAPI repo +deferred_items: + - id: org-contact-type + title: "Mark contacts as org type" + description: > + Requires a new contact_type lexicon term ("Organization"), an Alembic + migration, API filter support, and UI filtering/display changes in both + repos. Would make org-only contacts filterable and visually distinct + from person contacts. + effort: medium + priority: low + - id: confidential-org-masking + title: "Mask organization as well as name for confidential contacts" + description: > + sanitizeContact currently replaces name with "Confidential Contact" but + leaves organization visible. For org-only contacts the org becomes the + display name, which may expose sensitive information. sanitizeContact + should be updated to also blank organization when release_status is + private and the viewer lacks confidential access. As of 2026-05-01, + 2,052 of 2,126 contacts (96%) are private, so this gap affects nearly + all contact records for viewers without elevated access. + effort: low + priority: medium +--- + +# Contact Display Name + +## Problem + +The `Contact` data model allows `name` to be null when `organization` is +present (the API enforces that at least one of the two fields is set). This +means org-only contacts have always had a blank display name everywhere in +the UI: the wells list contacts column, the well detail Contacts card, the +contacts list page, and contact search results. + +## Resolution logic + +A single utility function in `src/utils/contactDisplayName.ts` resolves the +display name for any contact object: + +``` +getContactDisplayName(contact): + 1. name (non-empty after trim) → return name + 2. organization (non-empty) → return organization + 3. neither → return "" (guarded by API validation) +``` + +For a list of contacts (for example the Contacts column on the wells list): + +``` +getContactsLabel(contacts[]): + map each contact through getContactDisplayName, + filter out blank results, + join with ", " +``` + +## All edge cases + +| Scenario | name | organization | Display value | +|---|---|---|---| +| Person only | "Matt Zwager" | null | "Matt Zwager" | +| Person + org | "Matt Zwager" | "NMBGMR" | "Matt Zwager" | +| Org only | null | "NPS" | "NPS" | +| Both null | null | null | "" (blocked by API) | +| Empty string name | "" | "NPS" | "NPS" (trim treats "" as null) | +| Confidential person | "Confidential Contact" | "NMBGMR" | "Confidential Contact" | +| Confidential org-only | "Confidential Contact" | "NPS" | "Confidential Contact" | + +**Important:** `getContactDisplayName` must be called on the contact value +*after* `sanitizeContact` has run. `sanitizeContact` sets `name` to +`"Confidential Contact"` for private contacts when the viewer lacks +confidential access. Calling `getContactDisplayName` first and then +`sanitizeContact` would cause org-only private contacts to fall through +to their organization value and expose it. + +## Current contact_type values + +The `contact_type` lexicon category currently has three terms: + +- **Primary** — the main contact for a well +- **Secondary** — an additional contact for a well +- **Field Event Participant** — a person who participated in a field event + +None of these distinguish a person from an organization. This is tracked as a +deferred item (`org-contact-type`) for a future cycle. + +## Why the utility function approach was chosen + +The two data-level alternatives were considered and rejected: + +- **Copy org name into the name field**: Would conflict with the + `uq_contact_name_organization` unique constraint for rows where org already + equals name. Also creates duplicated data that must stay in sync. +- **Mark contacts as org type**: Valid longer-term but requires schema changes, + a migration, and API and UI filtering work in both repos. + +The utility function fixes all four display surfaces immediately with no data +migration, no schema change, and one small API label fix. + +## Where the logic is applied + +| Surface | File | Change | +|---|---|---| +| Search results label | `api/search.py` (OcotilloAPI) | `c.name` → `c.name or c.organization` | +| Wells list Contacts column | `src/pages/ocotillo/thing/list.tsx` | `valueGetter` and `renderCell` use `getContactDisplayName` | +| Well detail Contacts card | `src/components/WellShow/Contacts.tsx` | name gates use `getContactDisplayName`; org line suppressed when display name equals org | +| Contacts list Name column | `src/pages/ocotillo/contact/list.tsx` | `valueGetter` uses `getContactDisplayName` | +| Contact show page title | `src/pages/ocotillo/contact/show.tsx` | title uses `getContactDisplayName` | diff --git a/package-lock.json b/package-lock.json index 9074bb7c..24707b24 100644 --- a/package-lock.json +++ b/package-lock.json @@ -82,6 +82,7 @@ "@vitest/ui": "^3.2.4", "cypress": "^15.0.0", "jsdom": "^26.1.0", + "pdfjs-dist": "^5.7.284", "prettier": "3.5.3", "tailwindcss": "^3.4.19", "typescript": "^5.4.2", @@ -2858,6 +2859,268 @@ } } }, + "node_modules/@napi-rs/canvas": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.100.tgz", + "integrity": "sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==", + "dev": true, + "license": "MIT", + "optional": true, + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "0.1.100", + "@napi-rs/canvas-darwin-arm64": "0.1.100", + "@napi-rs/canvas-darwin-x64": "0.1.100", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.100", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.100", + "@napi-rs/canvas-linux-arm64-musl": "0.1.100", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.100", + "@napi-rs/canvas-linux-x64-gnu": "0.1.100", + "@napi-rs/canvas-linux-x64-musl": "0.1.100", + "@napi-rs/canvas-win32-arm64-msvc": "0.1.100", + "@napi-rs/canvas-win32-x64-msvc": "0.1.100" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.100.tgz", + "integrity": "sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.100.tgz", + "integrity": "sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.100.tgz", + "integrity": "sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.100.tgz", + "integrity": "sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.100.tgz", + "integrity": "sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.100.tgz", + "integrity": "sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.100.tgz", + "integrity": "sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.100.tgz", + "integrity": "sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.100.tgz", + "integrity": "sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-arm64-msvc": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.100.tgz", + "integrity": "sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.100.tgz", + "integrity": "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, "node_modules/@noble/ciphers": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", @@ -18407,6 +18670,19 @@ "pbf": "bin/pbf" } }, + "node_modules/pdfjs-dist": { + "version": "5.7.284", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.7.284.tgz", + "integrity": "sha512-h4EdYQczmGhbOlqc3PPZwxevn7ApdWPbovAuWXOB/DjIyigSnwfy2oze7c6mRcSr9XgLp3eN3EeL4DyySTPMFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=22.13.0 || >=24" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^0.1.100" + } + }, "node_modules/pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", diff --git a/package.json b/package.json index d83d7846..68fe3cf3 100644 --- a/package.json +++ b/package.json @@ -106,6 +106,7 @@ "@vitest/ui": "^3.2.4", "cypress": "^15.0.0", "jsdom": "^26.1.0", + "pdfjs-dist": "^5.7.284", "prettier": "3.5.3", "tailwindcss": "^3.4.19", "typescript": "^5.4.2", diff --git a/src/analytics/posthog.ts b/src/analytics/posthog.ts index 6f5d8d9d..4dd2c96a 100644 --- a/src/analytics/posthog.ts +++ b/src/analytics/posthog.ts @@ -19,6 +19,21 @@ export const initPostHog = () => { capture_pageview: false, capture_pageleave: true, capture_exceptions: true, + session_recording: { + maskInputFn: (text, element) => { + const el = element as HTMLInputElement | undefined + if (el?.type === 'password') { + return '*'.repeat(text.length) + } + if ( + el?.hasAttribute?.('data-posthog-unmask-search') || + el?.closest?.('[data-posthog-unmask-search]') + ) { + return text + } + return '*'.repeat(text.length) + }, + }, }) // Tag every event with the environment so staging visits are @@ -28,12 +43,48 @@ export const initPostHog = () => { initialized = true } -export const capturePostHogPageview = (path: string) => { +/** + * Optional properties for well detail pages so `well_id` is on `$pageview` + * (and shows up in PostHog when breaking down or filtering). + */ +export const wellDetailPageviewProps = ( + pathname: string +): + | { + well_id: string + page_template: 'well_detail' + well_detail_area: 'ocotillo' | 'amp' + } + | undefined => { + const ocotillo = pathname.match(/^\/ocotillo\/well\/show\/([^/]+)\/?$/) + if (ocotillo) { + return { + well_id: ocotillo[1], + page_template: 'well_detail', + well_detail_area: 'ocotillo', + } + } + const amp = pathname.match(/^\/amp\/wells\/show\/([^/]+)\/?$/) + if (amp) { + return { + well_id: amp[1], + page_template: 'well_detail', + well_detail_area: 'amp', + } + } + return undefined +} + +export const capturePostHogPageview = ( + path: string, + extras?: Record +) => { if (!isEnabled || !initialized) return posthog.capture('$pageview', { $current_url: window.location.href, path, + ...(extras ?? {}), }) } diff --git a/src/components/ListPage.tsx b/src/components/ListPage.tsx index e2436d59..5bca585f 100644 --- a/src/components/ListPage.tsx +++ b/src/components/ListPage.tsx @@ -74,12 +74,24 @@ function ActiveFilterChips() { ) } +type ListPageToolbarProps = { + hideFilter?: boolean + hideColumns?: boolean + hideDensity?: boolean + hideExport?: boolean +} + // Toolbar inside the DataGrid: // - Row 1 (right-aligned): filter, columns, density, export buttons // - Row 2 (only when filters are active): dismissible filter chips // The search input lives OUTSIDE the DataGrid to avoid focus-loss on re-render. // Built-in toolbar buttons are used (not custom icon buttons) so panels anchor correctly. -function ListPageToolbar() { +function ListPageToolbar({ + hideFilter = false, + hideColumns = false, + hideDensity = false, + hideExport = false, +}: ListPageToolbarProps) { return ( - - - - + {!hideFilter && ( + + )} + + {!hideColumns && ( + + )} + + {!hideDensity && ( + + )} + + {!hideExport && ( + + )} + ) @@ -128,6 +154,7 @@ type ListPageProps = { headerButtons?: any disableRowClick?: boolean + toolbarOptions?: ListPageToolbarProps searchMode?: 'client' | 'server' searchValue?: string onSearchChange?: (value: string) => void @@ -148,6 +175,8 @@ export const ListPage: React.FC = ({ isLoading, headerButtons, disableRowClick = false, + + toolbarOptions, searchMode = 'client', searchValue, onSearchChange, @@ -221,6 +250,10 @@ export const ListPage: React.FC = ({ } } + const toolbarConfig = { + hideExport: restDataGridProps.paginationMode === 'server', + } + return ( = ({ showToolbar slots={{ toolbar: ListPageToolbar }} slotProps={{ + toolbar: { + ...toolbarOptions, + hideExport: + toolbarOptions?.hideExport ?? + restDataGridProps.paginationMode === 'server', + }, loadingOverlay: { variant: 'linear-progress', noRowsVariant: 'skeleton', diff --git a/src/components/SearchModal.tsx b/src/components/SearchModal.tsx index 7b605045..52e01b21 100644 --- a/src/components/SearchModal.tsx +++ b/src/components/SearchModal.tsx @@ -84,7 +84,10 @@ export const SearchModal = ({ open, onClose }: SearchModalProps) => { placeholder="Search" fullWidth sx={{ fontSize: 15 }} - inputProps={{ 'aria-label': 'Search' }} + inputProps={{ + 'aria-label': 'Search', + 'data-posthog-unmask-search': true, + }} endAdornment={ state.query ? ( diff --git a/src/components/WellShow/Contacts.tsx b/src/components/WellShow/Contacts.tsx index 9b66d172..1e0bacc8 100644 --- a/src/components/WellShow/Contacts.tsx +++ b/src/components/WellShow/Contacts.tsx @@ -12,6 +12,7 @@ import { Directions } from '@mui/icons-material' import { Link as RouterLink } from 'react-router' import type { IContact } from '@/interfaces/ocotillo' import { formatPhone, formatContactAddress, formatAddress } from '@/utils' +import { getContactDisplayName } from '@/utils/contactDisplayName' const getGoogleMapsAddressUrl = (address: string) => { if (!address || address === 'N/A') return null @@ -40,6 +41,13 @@ const ContactBlock = ({ contact }: { contact: IContact }) => { const phones = contact.phones ?? [] const addresses = contact.addresses ?? [] + const displayName = getContactDisplayName(contact) + // When the contact has no personal name and the org is used as the display + // name, suppress the org line below to avoid showing it twice. + const isOrgOnlyContact = + !contact.name?.trim() && !!contact.organization?.trim() + const nameLabel = isOrgOnlyContact ? 'Organization' : 'Contact name' + return ( {roleType && ( @@ -47,16 +55,16 @@ const ContactBlock = ({ contact }: { contact: IContact }) => { {roleType} )} - {contact.name && ( + {displayName && ( - Contact name + {nameLabel} )} - {contact.name && contact.id && ( + {displayName && contact.id && ( { '&:hover': { textDecoration: 'underline' }, }} > - {contact.name} + {displayName} )} - {contact.name && !contact.id && ( + {displayName && !contact.id && ( - {contact.name} + {displayName} + + )} + {!isOrgOnlyContact && ( + + {contact.organization || 'No organization listed'} )} - - {contact.organization || 'No organization listed'} - {emails.length > 0 && ( { const location = useLocation() @@ -15,7 +19,8 @@ export const PostHogPageview = () => { if (lastPathRef.current === path) return - capturePostHogPageview(path) + const wellDetail = wellDetailPageviewProps(location.pathname) + capturePostHogPageview(path, wellDetail) lastPathRef.current = path }, [location.hash, location.pathname, location.search]) diff --git a/src/hooks/useSearchModalState.ts b/src/hooks/useSearchModalState.ts index 1686d777..07552ab4 100644 --- a/src/hooks/useSearchModalState.ts +++ b/src/hooks/useSearchModalState.ts @@ -1,5 +1,6 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { useGo } from '@refinedev/core' +import { captureEvent } from '@/analytics/posthog' import { GroupType } from '@/constants' import { useAbortableList } from './useAbortableList' import { useDebounce } from './useDebounce' @@ -109,6 +110,68 @@ export const useSearchModalState = ({ return searchDocs(parsed.term) }, [parsed]) + /** Avoid duplicate PostHog emissions when the debounced query or outcome repeats. */ + const defaultSearchEmittedKey = useRef(null) + useEffect(() => { + defaultSearchEmittedKey.current = null + }, [debounced]) + + /** + * PostHog: command palette API search (wells, contacts, assets). + * One event per completed search for a given debounced query string. + */ + useEffect(() => { + if (!open || parsed.mode !== 'default') return + const q = debounced.trim() + if (!q) return + if (searchQuery.isFetching) return + + const key = `${q}|${searchQuery.isError ? 1 : 0}|${results.length}` + if (defaultSearchEmittedKey.current === key) return + defaultSearchEmittedKey.current = key + + captureEvent('global_search', { + search_mode: 'default', + query: q, + result_count: results.length, + has_results: results.length > 0, + had_error: searchQuery.isError, + }) + }, [ + debounced, + open, + parsed.mode, + results.length, + searchQuery.isError, + searchQuery.isFetching, + ]) + + const docsSearchEmittedKey = useRef(null) + useEffect(() => { + docsSearchEmittedKey.current = null + }, [parsed.term]) + + /** + * PostHog: local docs search (!docs …). + */ + useEffect(() => { + if (!open || parsed.mode !== 'docs') return + const term = parsed.term.trim() + if (!term) return + + const key = `${term}|${docsResults.length}` + if (docsSearchEmittedKey.current === key) return + docsSearchEmittedKey.current = key + + captureEvent('global_search', { + search_mode: 'docs', + query: term, + result_count: docsResults.length, + has_results: docsResults.length > 0, + had_error: false, + }) + }, [docsResults.length, open, parsed.mode, parsed.term]) + const navigateToResult = (option: SearchResult) => { switch (option.group) { case GroupType.Wells: diff --git a/src/pages/home/index.tsx b/src/pages/home/index.tsx index e467e008..ccf4a19a 100644 --- a/src/pages/home/index.tsx +++ b/src/pages/home/index.tsx @@ -43,8 +43,7 @@ export const Home = () => { } const appEnv = import.meta.env.VITE_APP_ENV || 'production' -const showTestSiteBanner = - import.meta.env.DEV || appEnv !== 'production' +const showTestSiteBanner = import.meta.env.DEV || appEnv !== 'production' const HomeNotification = ({ noPermissions }) => { const [notificationOpen, setNotificationOpen] = useState(true) @@ -337,7 +336,7 @@ const About = () => { go({ to: '/ocotillo/help' })} + onClick={() => go({ to: '/ogcapi' })} > { type: 'string', minWidth: 160, flex: 1, + valueGetter: (_: unknown, row: IContact) => getContactDisplayName(row), }, { field: 'organization', diff --git a/src/pages/ocotillo/contact/show.tsx b/src/pages/ocotillo/contact/show.tsx index fbfdab20..f084129f 100644 --- a/src/pages/ocotillo/contact/show.tsx +++ b/src/pages/ocotillo/contact/show.tsx @@ -2,6 +2,7 @@ import { useShow } from '@refinedev/core' import { Show } from '@refinedev/mui' import { useAccessCapabilities } from '@/hooks' import { sanitizeContact } from '@/utils' +import { getContactDisplayName } from '@/utils/contactDisplayName' import { Box, Chip, Stack, Typography } from '@mui/material' import Grid from '@mui/material/Grid2' import { IContact } from '@/interfaces/ocotillo' @@ -47,7 +48,7 @@ export const ContactShow = () => { }} > - {contact?.name ?? ''} + {getContactDisplayName(contact ?? {})} {contact?.role && ( diff --git a/src/pages/ocotillo/thing/list.tsx b/src/pages/ocotillo/thing/list.tsx index b29c4f7b..3986183b 100644 --- a/src/pages/ocotillo/thing/list.tsx +++ b/src/pages/ocotillo/thing/list.tsx @@ -8,6 +8,7 @@ import { PictureAsPdf } from '@mui/icons-material' import { ListPage } from '@/components/ListPage' import { ISpring, IWell } from '@/interfaces/ocotillo' import { displayWellSiteName, formatAppDate, formatAppDateTime } from '@/utils' +import { getContactDisplayName } from '@/utils/contactDisplayName' import { WellListColumnLabels } from '@/well-list/wellListColumnLabels' export const SpringList: React.FC = () => { @@ -102,10 +103,12 @@ export const WellList: React.FC = () => { const { triggerExport, isLoading: exportIsLoading } = useExport({ resource: 'thing', dataProviderName: 'ocotillo', + pageSize: 500, meta: { params: { thing_type: ['water well', 'geothermal well'], include_contacts: true, + ...(search ? { name_contains: search } : {}), }, }, }) @@ -223,7 +226,7 @@ export const WellList: React.FC = () => { minWidth: 180, flex: 1, valueGetter: (_: unknown, row: IWell) => - row.contacts?.map((c) => c.name ?? '').join(', ') ?? '', + row.contacts?.map((c) => getContactDisplayName(c)).join(', ') ?? '', renderCell: (params) => { const contacts = params.row.contacts ?? [] return ( @@ -245,9 +248,11 @@ export const WellList: React.FC = () => { id: contact.id, }, }} - onClick={(e) => e.stopPropagation()} + onClick={(e: React.MouseEvent) => + e.stopPropagation() + } > - {contact.name} + {getContactDisplayName(contact)} ))} @@ -265,7 +270,8 @@ export const WellList: React.FC = () => { { field: 'well_driller_name', headerName: WellListColumnLabels.driller, - description: 'Drilling company name when it was recorded for this well.', + description: + 'Drilling company name when it was recorded for this well.', type: 'string', minWidth: 150, flex: 1, diff --git a/src/pages/ocotillo/thing/well-show.tsx b/src/pages/ocotillo/thing/well-show.tsx index 7db4699a..2889e0d7 100644 --- a/src/pages/ocotillo/thing/well-show.tsx +++ b/src/pages/ocotillo/thing/well-show.tsx @@ -65,7 +65,11 @@ export const WellShow = () => { useEffect(() => { if (id) - captureEvent('feature_used', { feature: 'well_detail', well_id: id }) + captureEvent('feature_used', { + feature: 'well_detail', + well_id: id, + well_detail_area: 'ocotillo', + }) }, [id]) const detailsQuery = useQuery({ @@ -84,9 +88,7 @@ export const WellShow = () => { // debugging. Visible only in the browser console when it is open. const label = `[ocotillo] GET thing/water-well/${id}/details` try { - const plain = JSON.parse( - JSON.stringify(data) - ) as IWellDetails + const plain = JSON.parse(JSON.stringify(data)) as IWellDetails console.log(label, plain) } catch { console.log(label, data) @@ -97,7 +99,7 @@ export const WellShow = () => { return data }, }) - const { canManageAmp } = useAccessCapabilities() + const { canViewAmp } = useAccessCapabilities() const { result: assetResult, query: assetQuery } = useList({ resource: 'asset', @@ -352,7 +354,7 @@ export const WellShow = () => { }} contentProps={{ sx: { pt: 1 } }} headerButtons={() => - canManageAmp ? ( + canViewAmp ? ( ): IContact => ({ @@ -70,15 +71,27 @@ const decodePdfStreams = (pdfText: string) => { return decoded.join('\n') } -const decodePdfHexStrings = (decodedPdfText: string) => { - const fragments: string[] = [] +const extractPdfText = async (pdfBlob: Blob) => { + const data = new Uint8Array(await pdfBlob.arrayBuffer()) - for (const match of decodedPdfText.matchAll(/<([0-9A-Fa-f]+)>/g)) { - const hex = match[1] - fragments.push(Buffer.from(hex, 'hex').toString('latin1')) + const document = await pdfjsLib.getDocument({ + data, + }).promise + + const pages: string[] = [] + + for (let pageNumber = 1; pageNumber <= document.numPages; pageNumber++) { + const page = await document.getPage(pageNumber) + const textContent = await page.getTextContent() + + pages.push(textContent.items.map((item: any) => item.str).join('')) } - return fragments.join('') + return { + pageCount: document.numPages, + text: pages.join('\n'), + pages, + } } describe('formatContactPhones', () => { @@ -155,7 +168,7 @@ describe('formatContactPhones', () => { }) describe('FieldCompilationNotesPdf', () => { - it('appends a final blank page with the requested text when no hydrograph image is provided', async () => { + it('renders all expected pages and content when no hydrograph image is provided', async () => { const pdfBlob = await pdf( createElement(FieldCompilationNotesPdf, { well: makeWell(), @@ -167,17 +180,13 @@ describe('FieldCompilationNotesPdf', () => { }) as any ).toBlob() - const pdfText = Buffer.from(await pdfBlob.arrayBuffer()).toString('latin1') + const { pageCount, pages } = await extractPdfText(pdfBlob) - const pageMatches = pdfText.match(/\/Type \/Page\b/g) ?? [] - const decodedText = decodePdfStreams(pdfText) - const decodedVisibleText = decodePdfHexStrings(decodedText) + expect(pageCount).toBe(4) - expect(pageMatches).toHaveLength(4) - expect(decodedVisibleText).toContain( - 'This page is intentionally left blank' - ) - expect(decodedVisibleText).toContain('Field Compilation Notes') - expect(decodedVisibleText).toContain('General Field Notes: Well-1') + expect(pages[0]).toContain('Field Compilation Notes') + expect(pages[1]).toContain('General Field Notes: Well-1') + expect(pages[2]).toContain('Hydrograph unavailable for this well') + expect(pages[3]).toContain('This page is intentionally left blank') }) }) diff --git a/src/utils/contactDisplayName.ts b/src/utils/contactDisplayName.ts new file mode 100644 index 00000000..21fe8f46 --- /dev/null +++ b/src/utils/contactDisplayName.ts @@ -0,0 +1,34 @@ +/** + * Utilities for resolving a consistent display name for a contact record. + * + * Full logic spec: feature_files/contact-display-name.md + * + * Resolution order (single contact): + * 1. name (non-empty after trim) → return name + * 2. organization (non-empty) → return organization + * 3. neither → return "" (guarded by API validation) + * + * Note: always call getContactDisplayName on the value *after* sanitizeContact + * has run so that confidential contacts surface as "Confidential Contact" + * rather than falling through to their organization field. + */ + +export interface ContactDisplayFields { + name?: string | null + organization?: string | null +} + +export function getContactDisplayName(contact: ContactDisplayFields): string { + const name = contact.name?.trim() + if (name) return name + return contact.organization?.trim() ?? '' +} + +/** + * Returns a comma-separated label for a list of contacts. + * Each contact is resolved through getContactDisplayName; blank results are + * filtered out before joining. + */ +export function getContactsLabel(contacts: ContactDisplayFields[]): string { + return contacts.map(getContactDisplayName).filter(Boolean).join(', ') +}