From ff8c8fc1efe980417e136177582a759ac84ca2db Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Mon, 27 Apr 2026 15:09:13 -0400 Subject: [PATCH 01/28] Well list: lead with name, site name, monitoring, created; column hints Reorder DataGrid columns so Name, Site name, Monitoring, and Created At appear first. Add site_name column with empty-string fallback. Set description on each column for MUI header accessibility and columns panel. --- src/pages/ocotillo/thing/list.tsx | 57 ++++++++++++++++++++++++++----- 1 file changed, 48 insertions(+), 9 deletions(-) diff --git a/src/pages/ocotillo/thing/list.tsx b/src/pages/ocotillo/thing/list.tsx index d846452e..b52c8252 100644 --- a/src/pages/ocotillo/thing/list.tsx +++ b/src/pages/ocotillo/thing/list.tsx @@ -116,31 +116,58 @@ export const WellList: React.FC = () => { { field: 'name', headerName: 'Name', + description: + 'Official well identifier used in bureau records (for example county prefix and local ID).', type: 'string', minWidth: 100, flex: 1, }, { - field: 'well_status', - headerName: 'Well Status', + field: 'site_name', + headerName: 'Site name', + description: + 'Name of the monitoring site or facility associated with this well when one is recorded.', type: 'string', - width: 150, + minWidth: 140, + flex: 0.9, + valueGetter: (_: unknown, row: IWell) => row.site_name ?? '', }, { field: 'monitoring_status', headerName: 'Monitoring', + description: + 'Whether the well is actively monitored or how monitoring is categorized in the current record.', type: 'string', width: 160, }, + { + field: 'created_at', + headerName: 'Created At', + description: + 'Date and time this well record was first created in Ocotillo.', + width: 180, + valueGetter: (v: string) => formatAppDateTime(v), + }, + { + field: 'well_status', + headerName: 'Well Status', + description: 'Operational or administrative status of the well.', + type: 'string', + width: 150, + }, { field: 'thing_type', headerName: 'Type', + description: + 'Infrastructure type from the controlled vocabulary (for example water well or geothermal well).', type: 'string', width: 130, }, { field: 'aquifers', headerName: 'Aquifers', + description: + 'Aquifer systems linked to this well, summarized from association data.', minWidth: 180, flex: 1, sortable: false, @@ -155,12 +182,16 @@ export const WellList: React.FC = () => { { field: 'release_status', headerName: 'Release Status', + description: + 'Whether the record is released for public viewing under data release rules.', type: 'string', width: 130, }, { field: 'well_depth', headerName: 'Well Depth (ft)', + description: + 'Completed well depth from ground surface to bottom of the well in feet.', type: 'number', width: 130, align: 'right', @@ -169,6 +200,8 @@ export const WellList: React.FC = () => { { field: 'hole_depth', headerName: 'Hole Depth (ft)', + description: + 'Total drilled hole depth from ground surface to bottom of the borehole in feet.', type: 'number', width: 130, align: 'right', @@ -177,12 +210,16 @@ export const WellList: React.FC = () => { { field: 'first_visit_date', headerName: 'First Visit', + description: + 'Date of the bureau first recorded visit to this well when available.', width: 130, valueGetter: (v: string) => formatAppDate(v), }, { field: 'contacts', headerName: 'Contacts', + description: + 'People or organizations linked to this well; open a contact from the link.', minWidth: 180, flex: 1, sortable: false, @@ -222,12 +259,14 @@ export const WellList: React.FC = () => { { field: 'well_completion_date', headerName: 'Completed', + description: 'Reported date the well construction was completed.', width: 130, valueGetter: (v: string) => formatAppDate(v), }, { field: 'well_driller_name', headerName: 'Driller', + description: 'Drilling company name when it was recorded for this well.', type: 'string', minWidth: 150, flex: 1, @@ -235,6 +274,8 @@ export const WellList: React.FC = () => { { field: 'latitude', headerName: 'Latitude', + description: + 'Latitude of the current mapped location in decimal degrees (WGS84).', type: 'number', width: 110, sortable: false, @@ -246,6 +287,8 @@ export const WellList: React.FC = () => { { field: 'longitude', headerName: 'Longitude', + description: + 'Longitude of the current mapped location in decimal degrees (WGS84).', type: 'number', width: 110, sortable: false, @@ -257,6 +300,8 @@ export const WellList: React.FC = () => { { field: 'alternate_ids', headerName: 'Alternate IDs', + description: + 'Identifiers from other agencies or programs that cross reference this well.', minWidth: 160, flex: 1, sortable: false, @@ -265,12 +310,6 @@ export const WellList: React.FC = () => { ?.map((a) => `${a.alternate_organization}: ${a.alternate_id}`) .join(', ') ?? '', }, - { - field: 'created_at', - headerName: 'Created At', - width: 180, - valueGetter: (v: string) => formatAppDateTime(v), - }, ], [] ) From a74c0c3ee4bdd3dd351dbf1778f26a0ad01b7a51 Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Mon, 27 Apr 2026 15:32:24 -0400 Subject: [PATCH 02/28] Wells list: show Created At as date only without time Use formatAppDate instead of formatAppDateTime for the Created At column and tighten column width. --- src/pages/ocotillo/thing/list.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pages/ocotillo/thing/list.tsx b/src/pages/ocotillo/thing/list.tsx index b52c8252..1b2d193e 100644 --- a/src/pages/ocotillo/thing/list.tsx +++ b/src/pages/ocotillo/thing/list.tsx @@ -144,9 +144,9 @@ export const WellList: React.FC = () => { field: 'created_at', headerName: 'Created At', description: - 'Date and time this well record was first created in Ocotillo.', - width: 180, - valueGetter: (v: string) => formatAppDateTime(v), + 'Calendar date when this well record was first added to Ocotillo.', + width: 130, + valueGetter: (v: string) => formatAppDate(v), }, { field: 'well_status', From 5fe0f682786b123235005bc899925e907e61dad0 Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Mon, 27 Apr 2026 15:45:06 -0400 Subject: [PATCH 03/28] Add displayWellSiteName helper for well list and contacts card Use API site_name with NMBGMR alternate_id fallback to match backend Thing.site_name. Apply on wells list column and well show ContactsCard. --- src/pages/ocotillo/thing/list.tsx | 4 ++-- src/pages/ocotillo/thing/well-show.tsx | 3 ++- src/utils/index.ts | 1 + src/utils/wellSiteName.ts | 18 ++++++++++++++++++ 4 files changed, 23 insertions(+), 3 deletions(-) create mode 100644 src/utils/wellSiteName.ts diff --git a/src/pages/ocotillo/thing/list.tsx b/src/pages/ocotillo/thing/list.tsx index 1b2d193e..e4ca8731 100644 --- a/src/pages/ocotillo/thing/list.tsx +++ b/src/pages/ocotillo/thing/list.tsx @@ -7,7 +7,7 @@ import { Button } from '@mui/material' import { PictureAsPdf } from '@mui/icons-material' import { ListPage } from '@/components/ListPage' import { ISpring, IWell } from '@/interfaces/ocotillo' -import { formatAppDate, formatAppDateTime } from '@/utils' +import { displayWellSiteName, formatAppDate, formatAppDateTime } from '@/utils' export const SpringList: React.FC = () => { const { dataGridProps } = useDataGrid({ @@ -130,7 +130,7 @@ export const WellList: React.FC = () => { type: 'string', minWidth: 140, flex: 0.9, - valueGetter: (_: unknown, row: IWell) => row.site_name ?? '', + valueGetter: (_: unknown, row: IWell) => displayWellSiteName(row), }, { field: 'monitoring_status', diff --git a/src/pages/ocotillo/thing/well-show.tsx b/src/pages/ocotillo/thing/well-show.tsx index 1e33c9b4..307aff07 100644 --- a/src/pages/ocotillo/thing/well-show.tsx +++ b/src/pages/ocotillo/thing/well-show.tsx @@ -43,6 +43,7 @@ import { MonitoringInfoCard, WaterLevelObservationRow, } from '@/components' +import { displayWellSiteName } from '@/utils' const EMPTY_ASSETS: IAsset[] = [] const EMPTY_CONTACTS: IContact[] = [] @@ -385,7 +386,7 @@ export const WellShow = () => { +): string { + const fromApi = well.site_name?.trim() + if (fromApi) return fromApi + + const links = [...(well.alternate_ids ?? [])].sort((a, b) => a.id - b.id) + const nmbgmr = links.find( + (link) => link.alternate_organization?.toUpperCase() === 'NMBGMR' + ) + return nmbgmr?.alternate_id ?? '' +} From d93489dc90848bbedfa6c1f18ae86bb639b3faad Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Tue, 28 Apr 2026 10:12:50 -0400 Subject: [PATCH 04/28] Align wells and contacts grids with API virtual sorts and document filter limits Enable sorting on Associated Sites, Aquifers, and Contacts where the API defines rules and add column descriptions for sort semantics. Update data-tables.md for MUI X Community single column filter in the panel, multi-filter capability on the API side, and refresh sortability in the column tables. Clarify site name description and simplify README deployment hosting examples. --- README.md | 3 +-- docs/data-tables.md | 17 +++++++++++------ src/pages/ocotillo/contact/list.tsx | 3 ++- src/pages/ocotillo/thing/list.tsx | 8 +++----- 4 files changed, 17 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 21aed4b9..3081c4da 100644 --- a/README.md +++ b/README.md @@ -180,8 +180,7 @@ npm run start ## Deployment -Deploy the contents of the `dist/` folder to any static hosting provider (e.g., Netlify, Vercel, AWS S3, GitHub Pages, -GCP). Ensure environment variables are configured on the hosting platform. +Deploy the contents of the `dist/` folder to any static hosting provider (e.g. GCP). Ensure environment variables are configured on the hosting platform. ## License diff --git a/docs/data-tables.md b/docs/data-tables.md index 96733d9e..715e4bcc 100644 --- a/docs/data-tables.md +++ b/docs/data-tables.md @@ -31,9 +31,11 @@ Limitation: it does not search your entire database. If the record you are looki ### Column filters (server-side filters) -The **Filters** button opens a panel where you can add one or more conditions to narrow down results across the entire dataset. This sends a request to the server and returns only the matching records. +The **Filters** button opens a panel where you set conditions that narrow results across the **entire** dataset. Each change sends a request to the server. -Currently supported filter operators: +**Important:** The app uses **MUI X Data Grid (Community)**. In this edition, the filter panel only supports **one active column filter at a time**. If you add a filter on a second column, it **replaces** the previous one rather than stacking. The Ocotillo API and Refine can in principle accept **multiple** filter parameters in one request, but the current grid UI does not let you combine several column filters without upgrading to a commercial Data Grid tier or building a custom filter UI. + +Currently supported filter operators (when the API implements them for that column): - **contains** -- finds records where the field includes the search text - **equals** -- finds records where the field exactly matches @@ -45,9 +47,11 @@ Other operators shown in the dropdown (such as "does not contain" or "does not e ### Sorting -Clicking any column header sorts the table by that column. Clicking again reverses the sort order. Sorting is applied server-side, so it sorts across all records, not just the current page. +Clicking any column header sorts the table by that column when the column is sortable. Clicking again reverses the sort order. Sorting is applied server-side, so it sorts across all records, not just the current page. + +For columns that aggregate multiple links (for example **Aquifers**, **Contacts** on wells, or **Associated Sites** on contacts), the server uses a fixed rule such as ordering by the alphabetically first linked value. That order may not match how labels are joined with commas in the cell. -Columns marked as not sortable (such as Aquifers and Associated Sites) cannot be sorted because they contain computed or multi-value data that doesn't translate well to a single sort order. +Some columns stay non-sortable by design (for example **Primary Phone** and **Primary Email** on Contacts), usually because sorting would require extra API work or privacy rules. ### Column visibility @@ -79,6 +83,7 @@ The following improvements would require changes to the OcotilloAPI server in ad - **Additional filter operators** -- "does not contain," "does not equal," and "is any of" would need to be implemented in the API filter logic. - **Additional columns** -- several useful fields (owner name, county, latitude/longitude, site name) exist in the database but are not currently returned by the API in the list endpoints. Adding them requires API and potentially database changes. - **Saved views or presets** -- letting users save a filter configuration and return to it later would need storage on the server or in user preferences. +- **Multiple column filters visible at once in the filter panel** -- the Ocotillo API can accept more than one `filter` query parameter, but **MUI X Data Grid (Community)** only allows **one** active column filter in the built-in panel. Offering several simultaneous column filters in that panel would need a **custom filter UI** (or a different table stack), not only an API change. ## Column reference by page @@ -89,7 +94,7 @@ The following improvements would require changes to the OcotilloAPI server in ad | Name | Well identifier (e.g. WELL-0001) | Yes | | Well Status | Current operational status | Yes | | Monitoring | Whether the well is actively monitored | Yes | -| Aquifers | Aquifer systems the well is associated with | No | +| Aquifers | Aquifer systems the well is associated with | Yes (first aquifer name alphabetically) | | Release Status | Whether the record is public | Yes | | Well Depth (ft) | Total depth of the well casing | Yes | | Hole Depth (ft) | Total drilled depth | Yes | @@ -124,5 +129,5 @@ The following improvements would require changes to the OcotilloAPI server in ad | Contact Type | Classification of the contact | Yes | | Primary Phone | First phone number on file | No | | Primary Email | First email address on file | No | -| Associated Sites | Wells or springs linked to this contact | No | +| Associated Sites | Wells or springs linked to this contact | Yes (first site name alphabetically) | | Created At | When the record was added to the system | Yes | diff --git a/src/pages/ocotillo/contact/list.tsx b/src/pages/ocotillo/contact/list.tsx index 18c1d456..1041548c 100644 --- a/src/pages/ocotillo/contact/list.tsx +++ b/src/pages/ocotillo/contact/list.tsx @@ -114,10 +114,11 @@ export const ContactList: React.FC = () => { { field: 'things', headerName: 'Associated Sites', + description: + 'Monitoring sites linked to this contact. Sort uses the alphabetically first linked site name.', type: 'string', minWidth: 180, flex: 1, - sortable: false, valueGetter: (_: unknown, row: IContact) => row.things?.map((thing) => thing.name).join('; ') ?? '', renderCell: (params) => { diff --git a/src/pages/ocotillo/thing/list.tsx b/src/pages/ocotillo/thing/list.tsx index 8b0abc72..7469ce87 100644 --- a/src/pages/ocotillo/thing/list.tsx +++ b/src/pages/ocotillo/thing/list.tsx @@ -126,7 +126,7 @@ export const WellList: React.FC = () => { field: 'site_name', headerName: 'Site name', description: - 'Name of the monitoring site or facility associated with this well when one is recorded.', + 'Name of the monitoring site or facility associated with this well when one is recorded (NMBGMR alternate ID when present).', type: 'string', minWidth: 140, flex: 0.9, @@ -167,10 +167,9 @@ export const WellList: React.FC = () => { field: 'aquifers', headerName: 'Aquifers', description: - 'Aquifer systems linked to this well, summarized from association data.', + 'Aquifer systems linked to this well, summarized from association data. Sort uses the first aquifer name alphabetically among linked systems.', minWidth: 180, flex: 1, - sortable: false, valueGetter: (_: unknown, row: IWell) => row.aquifers ?.map( @@ -219,10 +218,9 @@ export const WellList: React.FC = () => { field: 'contacts', headerName: 'Contacts', description: - 'People or organizations linked to this well; open a contact from the link.', + 'People or organizations linked to this well; open a contact from the link. Sort uses the alphabetically first linked contact name.', minWidth: 180, flex: 1, - sortable: false, valueGetter: (_: unknown, row: IWell) => row.contacts?.map((c) => c.name ?? '').join(', ') ?? '', renderCell: (params) => { From daffbc1b3bc4148d4e5d9199ef92d7716d6c7b82 Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Tue, 28 Apr 2026 10:26:55 -0400 Subject: [PATCH 05/28] Restore filter debounce on server-side DataGrids so filter inputs keep focus while typing Refine sets filterDebounceMs to 0 for server mode; override to 350ms when filterMode is server so column toolbar filters do not lose focus on each keystroke. --- src/components/ListPage.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/components/ListPage.tsx b/src/components/ListPage.tsx index 912db162..54ffbf87 100644 --- a/src/components/ListPage.tsx +++ b/src/components/ListPage.tsx @@ -320,9 +320,15 @@ export const ListPage: React.FC = ({ )} + {/* Refine sets filterDebounceMs to 0 for server-side grids; restore MUI debounce so toolbar column filters keep input focus while typing. */} Date: Tue, 28 Apr 2026 10:29:56 -0400 Subject: [PATCH 06/28] update to 700 --- src/components/ListPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ListPage.tsx b/src/components/ListPage.tsx index 54ffbf87..e2436d59 100644 --- a/src/components/ListPage.tsx +++ b/src/components/ListPage.tsx @@ -326,7 +326,7 @@ export const ListPage: React.FC = ({ rows={filteredRows} filterDebounceMs={ restDataGridProps.filterMode === 'server' - ? 350 + ? 700 : restDataGridProps.filterDebounceMs } showToolbar From 23f1f13eeb58dfccfaaeacbeb4eee4408fda3de1 Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Tue, 28 Apr 2026 10:49:37 -0400 Subject: [PATCH 07/28] Log well details API payload to the console in all environments After each successful thing/water-well details fetch, log a JSON snapshot and pretty-printed copy so the full response is inspectable in DevTools in dev, staging, and production. --- src/pages/ocotillo/thing/well-show.tsx | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/pages/ocotillo/thing/well-show.tsx b/src/pages/ocotillo/thing/well-show.tsx index 613441e1..ff43dbba 100644 --- a/src/pages/ocotillo/thing/well-show.tsx +++ b/src/pages/ocotillo/thing/well-show.tsx @@ -81,7 +81,22 @@ export const WellShow = () => { method: 'get', }) - return response.data as IWellDetails + const data = response.data as IWellDetails + // Log full details payload in every environment (dev, staging, prod) for + // 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 + console.log(label, plain) + } catch { + console.log(label, data) + } + console.log( + `${label} (full JSON, scroll or copy this if the object above will not expand)\n${JSON.stringify(data, null, 2)}` + ) + return data }, }) const { canManageAmp } = useAccessCapabilities() From f5fbd047019c1547792fb62c86f02907c3685731 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Tue, 28 Apr 2026 10:30:53 -0500 Subject: [PATCH 08/28] refactor(components/WellShow): Update names from Accordion to Card --- src/components/WellShow/AlternateIds.tsx | 8 +- src/components/WellShow/Attachments.tsx | 2 +- src/components/WellShow/ConstructionInfo.tsx | 217 +++++++++++++++++- src/components/WellShow/Equipment.tsx | 8 +- .../WellShow/GeologyInformation.tsx | 6 +- .../WellShow/WellPhysicalProperties.tsx | 2 +- src/components/WellShow/WellScreens.tsx | 4 +- src/interfaces/ocotillo/IWell.ts | 2 + src/pages/ocotillo/thing/well-show.tsx | 29 ++- 9 files changed, 241 insertions(+), 37 deletions(-) diff --git a/src/components/WellShow/AlternateIds.tsx b/src/components/WellShow/AlternateIds.tsx index 850c19aa..134f7784 100644 --- a/src/components/WellShow/AlternateIds.tsx +++ b/src/components/WellShow/AlternateIds.tsx @@ -1,11 +1,11 @@ import { useMemo } from 'react' -import { Box, Paper, Stack, Typography } from '@mui/material' +import { Box, Paper, Typography } from '@mui/material' import type { UseDataGridReturnType } from '@refinedev/mui' import { MoreVertOutlined } from '@mui/icons-material' import { DataGrid, GridColDef } from '@mui/x-data-grid' import { settings } from '@/settings' -export const AlternateIdsAccordion = ({ +export const AlternateIdsCard = ({ dataGridProps, }: { dataGridProps: UseDataGridReturnType['dataGridProps'] @@ -25,7 +25,9 @@ export const AlternateIdsAccordion = ({ return ( - + Alternate IDs diff --git a/src/components/WellShow/Attachments.tsx b/src/components/WellShow/Attachments.tsx index c55cb9b1..bb4414af 100644 --- a/src/components/WellShow/Attachments.tsx +++ b/src/components/WellShow/Attachments.tsx @@ -23,7 +23,7 @@ import type { IAsset } from '@/interfaces/ocotillo' type ImageViewMode = 'grid' | 'slideshow' -export const AttachmentsAccordion = ({ +export const AttachmentsCard = ({ assets, isLoading, }: { diff --git a/src/components/WellShow/ConstructionInfo.tsx b/src/components/WellShow/ConstructionInfo.tsx index 7b44415a..3802a507 100644 --- a/src/components/WellShow/ConstructionInfo.tsx +++ b/src/components/WellShow/ConstructionInfo.tsx @@ -1,8 +1,26 @@ -import { Box, Paper, Stack, Typography } from '@mui/material' +import { useEffect, useMemo, useState } from 'react' +import { + Paper, + Box, + Stack, + Typography, + ToggleButtonGroup, + ToggleButton, +} from '@mui/material' import { IWell } from '@/interfaces/ocotillo' import { formatAppDate } from '@/utils' +import { INCHES_IN_A_FOOT } from '@/constants' +import { SupportedUnits } from '@/config' +import { convertFeetToInches, convertInchesToFeet, formatNumber } from '@/utils' + +export const ConstructionInfoCard = ({ well }: { well?: IWell }) => { + const elevation = well?.current_location?.properties?.elevation + const normalizedElevation = + elevation != null && elevation !== 0 ? elevation : null + const elevationUnit = well?.current_location?.properties?.elevation_unit + const elevationMethod = well?.current_location?.properties?.elevation_method + const verticalDatum = well?.current_location?.properties?.vertical_datum -export const ConstructionInfoAccordion = ({ well }: { well?: IWell }) => { return ( @@ -12,6 +30,37 @@ export const ConstructionInfoAccordion = ({ well }: { well?: IWell }) => { + + + + + + + + { label="Driller Name" value={well?.well_driller_name || 'N/A'} /> + + 0 + ? well.historic_depth_to_water.join(', ') + : 'No original depth to water available' + } + /> { ) } -const InlineRow = ({ label, value }: { label: string; value: string }) => ( - - {label}:{' '} - - {value} +const InlineRow = ({ label, value }: { label: string; value: string }) => { + const isLong = value.length > 30 + + return ( + + {label}:{' '} + + {value} + - -) + ) +} + +const getDefaultDisplayUnit = ( + value: number | null | undefined, + normalizedUnit: SupportedUnits | null +): SupportedUnits => { + const hasNumericValue = typeof value === 'number' && !Number.isNaN(value) + + if (normalizedUnit === 'in' && hasNumericValue) { + return value >= INCHES_IN_A_FOOT ? 'ft' : 'in' + } + + return 'ft' +} + +const InlineRowWithUnitConversion = ({ + label, + value, + unit, +}: { + label: string + value: number | null | undefined + unit: SupportedUnits | string | null | undefined +}) => { + const normalizedUnit: SupportedUnits | null = + unit === 'in' || unit === 'ft' ? unit : null + + const hasNumericValue = typeof value === 'number' && !Number.isNaN(value) + + const [displayUnit, setDisplayUnit] = useState(() => + getDefaultDisplayUnit(value, normalizedUnit) + ) + + useEffect(() => { + setDisplayUnit(getDefaultDisplayUnit(value, normalizedUnit)) + }, [value, normalizedUnit]) + + const displayValue = useMemo(() => { + if (!hasNumericValue) return null + + if (normalizedUnit === 'in') { + return displayUnit === 'ft' + ? convertInchesToFeet(value, { precision: 2 }) + : value + } + + if (normalizedUnit === 'ft') { + return displayUnit === 'in' + ? convertFeetToInches(value, { precision: 2 }) + : value + } + + return value + }, [displayUnit, normalizedUnit, value, hasNumericValue]) + + const handleUnitChange = ( + _event: React.MouseEvent, + nextUnit: SupportedUnits | null + ) => { + if (nextUnit) { + setDisplayUnit(nextUnit) + } + } + + if (!hasNumericValue) { + return + } + + const shouldShowToggle = normalizedUnit === 'in' || normalizedUnit === 'ft' + + return ( + + + {label}:{' '} + + {formatNumber(displayValue, { precision: 2 })} + {shouldShowToggle + ? ` ${displayUnit}` + : normalizedUnit + ? ` ${normalizedUnit}` + : ''} + + + + {shouldShowToggle && ( + ({ + '& .MuiToggleButton-root': { + color: 'text.secondary', + border: `1px solid ${theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.3)' : theme.palette.divider}`, + }, + '& .MuiToggleButton-root + .MuiToggleButton-root': { + borderLeft: `1px solid ${theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.3)' : theme.palette.divider}`, + marginLeft: 0, + }, + '& .MuiToggleButton-root.Mui-selected': { + bgcolor: 'primary.main', + color: 'primary.contrastText', + border: `1px solid ${theme.palette.primary.main} !important`, + '&:hover': { bgcolor: 'primary.dark' }, + }, + })} + > + + in + + + ft + + + )} + + ) +} diff --git a/src/components/WellShow/Equipment.tsx b/src/components/WellShow/Equipment.tsx index 268b7a3f..10b69732 100644 --- a/src/components/WellShow/Equipment.tsx +++ b/src/components/WellShow/Equipment.tsx @@ -8,7 +8,6 @@ import { GridToolbarContainer, GridToolbarDensitySelector, } from '@mui/x-data-grid' -import { settings } from '@/settings' import { ISensor } from '@/interfaces/ocotillo' import { useSensorDeploymentRows } from '@/hooks' import { SensorDeploymentRow } from '@/utils' @@ -20,7 +19,7 @@ const EquipmentToolbar = () => ( ) -export const EquipmentAccordion = ({ +export const EquipmentCard = ({ sensors, deployments, isDetailsPending, @@ -65,7 +64,10 @@ export const EquipmentAccordion = ({ const rowSelectionModel = useMemo( () => selectedEquipmentId != null - ? { type: 'include' as const, ids: new Set([selectedEquipmentId]) } + ? { + type: 'include' as const, + ids: new Set([selectedEquipmentId]), + } : { type: 'include' as const, ids: new Set() }, [selectedEquipmentId] ) diff --git a/src/components/WellShow/GeologyInformation.tsx b/src/components/WellShow/GeologyInformation.tsx index 4e930370..4a628c5d 100644 --- a/src/components/WellShow/GeologyInformation.tsx +++ b/src/components/WellShow/GeologyInformation.tsx @@ -1,7 +1,7 @@ import { Paper, Box, Stack, Typography } from '@mui/material' import { IWell } from '@/interfaces/ocotillo' -export const GeologyInformationAccordion = ({ well }: { well?: IWell }) => { +export const GeologyInformationCard = ({ well }: { well?: IWell }) => { return ( @@ -28,7 +28,9 @@ export const GeologyInformationAccordion = ({ well }: { well?: IWell }) => { label="Aquifer Types" value={ well?.aquifers && well.aquifers.length > 0 - ? [...new Set(well.aquifers.flatMap((a) => a.aquifer_types))].join(', ') + ? [ + ...new Set(well.aquifers.flatMap((a) => a.aquifer_types)), + ].join(', ') : 'N/A' } /> diff --git a/src/components/WellShow/WellPhysicalProperties.tsx b/src/components/WellShow/WellPhysicalProperties.tsx index fa7d1e62..98083cd8 100644 --- a/src/components/WellShow/WellPhysicalProperties.tsx +++ b/src/components/WellShow/WellPhysicalProperties.tsx @@ -12,7 +12,7 @@ import { INCHES_IN_A_FOOT } from '@/constants' import { SupportedUnits } from '@/config' import { convertFeetToInches, convertInchesToFeet, formatNumber } from '@/utils' -export const WellPhysicalPropertiesAccordion = ({ well }: { well?: IWell }) => { +export const WellPhysicalPropertiesCard = ({ well }: { well?: IWell }) => { const elevation = well?.current_location?.properties?.elevation const normalizedElevation = elevation != null && elevation !== 0 ? elevation : null diff --git a/src/components/WellShow/WellScreens.tsx b/src/components/WellShow/WellScreens.tsx index aa2bdec3..88fc267d 100644 --- a/src/components/WellShow/WellScreens.tsx +++ b/src/components/WellShow/WellScreens.tsx @@ -1,11 +1,11 @@ import { useMemo } from 'react' -import { Box, Paper, Stack, Typography } from '@mui/material' +import { Box, Paper, Typography } from '@mui/material' import { MoreVertOutlined } from '@mui/icons-material' import { DataGrid, GridColDef } from '@mui/x-data-grid' import { settings } from '@/settings' import type { IWellScreen } from '@/interfaces/ocotillo' -export const WellScreensAccordion = ({ +export const WellScreensCard = ({ rows, isLoading, }: { diff --git a/src/interfaces/ocotillo/IWell.ts b/src/interfaces/ocotillo/IWell.ts index 471095a9..40252533 100644 --- a/src/interfaces/ocotillo/IWell.ts +++ b/src/interfaces/ocotillo/IWell.ts @@ -63,4 +63,6 @@ export interface IWell extends IThing { contacts?: Partial[] | null site_name?: string | null + + historic_depth_to_water?: string[] | null } diff --git a/src/pages/ocotillo/thing/well-show.tsx b/src/pages/ocotillo/thing/well-show.tsx index 50be1c27..ead0032d 100644 --- a/src/pages/ocotillo/thing/well-show.tsx +++ b/src/pages/ocotillo/thing/well-show.tsx @@ -26,17 +26,16 @@ import { HydrographCard, RecentWaterLevelObservationsCard, ContactsCard, - AttachmentsAccordion, - AlternateIdsAccordion, + AttachmentsCard, + AlternateIdsCard, USGSInfoCard, OSEPODInfoCard, WellPDFPreviewButton, - WellScreensAccordion, - EquipmentAccordion, + WellScreensCard, + EquipmentCard, NotesAccordion, - ConstructionInfoAccordion, - GeologyInformationAccordion, - WellPhysicalPropertiesAccordion, + ConstructionInfoCard, + GeologyInformationCard, WellPDFDownloadButton, WellShowTitle, OwnerPermissionsCard, @@ -52,8 +51,7 @@ const EMPTY_WELL_SCREENS: IWellScreen[] = [] const EMPTY_FIELD_EVENTS: IFieldEvent[] = [] const EMPTY_PARTICIPANTS: IFieldEventParticipant[] = [] const EMPTY_MANUAL_HYDRO_ROWS: IObservation[] = [] -const EMPTY_TRANSDUCER_HYDRO_ROWS: TransducerObservationWithBlockResponse[] = - [] +const EMPTY_TRANSDUCER_HYDRO_ROWS: TransducerObservationWithBlockResponse[] = [] export const WellShow = () => { const dataProvider = useDataProvider() @@ -373,17 +371,17 @@ export const WellShow = () => { isLoading={isDetailsLoading} /> - - - - + @@ -407,9 +405,8 @@ export const WellShow = () => { isLoading={isDetailsLoading} /> - - - + + From a12db0efacbf4f545924f387b3324874b4133221 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Tue, 28 Apr 2026 10:33:13 -0500 Subject: [PATCH 09/28] chore(WellPhysicalProperties): Rm unused component --- .../WellShow/WellPhysicalProperties.tsx | 202 ------------------ src/components/WellShow/index.ts | 1 - 2 files changed, 203 deletions(-) delete mode 100644 src/components/WellShow/WellPhysicalProperties.tsx diff --git a/src/components/WellShow/WellPhysicalProperties.tsx b/src/components/WellShow/WellPhysicalProperties.tsx deleted file mode 100644 index 98083cd8..00000000 --- a/src/components/WellShow/WellPhysicalProperties.tsx +++ /dev/null @@ -1,202 +0,0 @@ -import { - Paper, - Box, - Stack, - Typography, - ToggleButtonGroup, - ToggleButton, -} from '@mui/material' -import { IWell } from '@/interfaces/ocotillo' -import { useEffect, useMemo, useState } from 'react' -import { INCHES_IN_A_FOOT } from '@/constants' -import { SupportedUnits } from '@/config' -import { convertFeetToInches, convertInchesToFeet, formatNumber } from '@/utils' - -export const WellPhysicalPropertiesCard = ({ well }: { well?: IWell }) => { - const elevation = well?.current_location?.properties?.elevation - const normalizedElevation = - elevation != null && elevation !== 0 ? elevation : null - const elevationUnit = well?.current_location?.properties?.elevation_unit - const elevationMethod = well?.current_location?.properties?.elevation_method - const verticalDatum = well?.current_location?.properties?.vertical_datum - - return ( - - - - Physical Properties - - - - - - - - - - - - - - - - ) -} - -const InlineRow = ({ label, value }: { label: string; value: string }) => ( - - {label}:{' '} - - {value} - - -) - -const InlineRowWithUnitConversion = ({ - label, - value, - unit, -}: { - label: string - value: number | null | undefined - unit: SupportedUnits | string | null | undefined -}) => { - const normalizedUnit: SupportedUnits | null = - unit === 'in' || unit === 'ft' ? unit : null - - const hasNumericValue = typeof value === 'number' && !Number.isNaN(value) - - const [displayUnit, setDisplayUnit] = useState(() => - getDefaultDisplayUnit(value, normalizedUnit) - ) - - useEffect(() => { - setDisplayUnit(getDefaultDisplayUnit(value, normalizedUnit)) - }, [value, normalizedUnit]) - - const displayValue = useMemo(() => { - if (!hasNumericValue) return null - - if (normalizedUnit === 'in') { - return displayUnit === 'ft' - ? convertInchesToFeet(value, { precision: 2 }) - : value - } - - if (normalizedUnit === 'ft') { - return displayUnit === 'in' - ? convertFeetToInches(value, { precision: 2 }) - : value - } - - return value - }, [displayUnit, normalizedUnit, value, hasNumericValue]) - - const handleUnitChange = ( - _event: React.MouseEvent, - nextUnit: SupportedUnits | null - ) => { - if (nextUnit) { - setDisplayUnit(nextUnit) - } - } - - if (!hasNumericValue) { - return - } - - const shouldShowToggle = normalizedUnit === 'in' || normalizedUnit === 'ft' - - return ( - - - {label}:{' '} - - {formatNumber(displayValue, { precision: 2 })} - {shouldShowToggle - ? ` ${displayUnit}` - : normalizedUnit - ? ` ${normalizedUnit}` - : ''} - - - - {shouldShowToggle && ( - ({ - '& .MuiToggleButton-root': { - color: 'text.secondary', - border: `1px solid ${theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.3)' : theme.palette.divider}`, - }, - '& .MuiToggleButton-root + .MuiToggleButton-root': { - borderLeft: `1px solid ${theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.3)' : theme.palette.divider}`, - marginLeft: 0, - }, - '& .MuiToggleButton-root.Mui-selected': { - bgcolor: 'primary.main', - color: 'primary.contrastText', - border: `1px solid ${theme.palette.primary.main} !important`, - '&:hover': { bgcolor: 'primary.dark' }, - }, - })} - > - - in - - - ft - - - )} - - ) -} - -const getDefaultDisplayUnit = ( - value: number | null | undefined, - normalizedUnit: SupportedUnits | null -): SupportedUnits => { - const hasNumericValue = typeof value === 'number' && !Number.isNaN(value) - - if (normalizedUnit === 'in' && hasNumericValue) { - return value >= INCHES_IN_A_FOOT ? 'ft' : 'in' - } - - return 'ft' -} diff --git a/src/components/WellShow/index.ts b/src/components/WellShow/index.ts index b96fd8a1..7abc3074 100644 --- a/src/components/WellShow/index.ts +++ b/src/components/WellShow/index.ts @@ -2,7 +2,6 @@ export * from './ConstructionInfo' export * from './MonitoringInfo' export * from './GeologyInformation' export * from './OwnerPermissions' -export * from './WellPhysicalProperties' export * from './Attachments' export * from './AlternateIds' export * from './Contacts' From c7ab6deae143abf7651d659b824b0b37253496ec Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Tue, 28 Apr 2026 11:11:30 -0500 Subject: [PATCH 10/28] test(pages/well-show): Fix vi.mock imports --- src/test/pages/well-show.test.tsx | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/test/pages/well-show.test.tsx b/src/test/pages/well-show.test.tsx index 01294cb6..bb41c2c2 100644 --- a/src/test/pages/well-show.test.tsx +++ b/src/test/pages/well-show.test.tsx @@ -48,17 +48,16 @@ vi.mock('@/components', () => { HydrographCard: () => , RecentWaterLevelObservationsCard: () => , ContactsCard: () => , - AttachmentsAccordion: () => , - AlternateIdsAccordion: () => , + AttachmentsCard: () => , + AlternateIdsCard: () => , USGSInfoCard: () => , OSEPODInfoCard: () => , WellPDFPreviewButton: () => , - WellScreensAccordion: () => , - EquipmentAccordion: () => , + WellScreensCard: () => , + EquipmentCard: () => , NotesAccordion: () => , - ConstructionInfoAccordion: () => , - GeologyInformationAccordion: () => , - WellPhysicalPropertiesAccordion: () => , + ConstructionInfoCard: () => , + GeologyInformationCard: () => , WellPDFDownloadButton: () => , WellShowTitle: () => , OwnerPermissionsCard: () => , From dd8758471fa51f1341527784fd055849960afc38 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Tue, 28 Apr 2026 12:08:15 -0500 Subject: [PATCH 11/28] feat(WellShow/OwnerPermissions): Add 'Owner acknowledged public data release' section --- src/components/WellShow/OwnerPermissions.tsx | 112 ++++++++++++++----- 1 file changed, 85 insertions(+), 27 deletions(-) diff --git a/src/components/WellShow/OwnerPermissions.tsx b/src/components/WellShow/OwnerPermissions.tsx index a5b14a96..7797d8cb 100644 --- a/src/components/WellShow/OwnerPermissions.tsx +++ b/src/components/WellShow/OwnerPermissions.tsx @@ -9,6 +9,10 @@ import { } from '@mui/material' import { IWell } from '@/interfaces/ocotillo' import { formatAppDate } from '@/utils' +import { z } from 'zod' +import { zReleaseStatus } from '@/generated/zod.gen' + +type ReleaseStatus = z.infer export const OwnerPermissionsCard = ({ well, @@ -17,6 +21,14 @@ export const OwnerPermissionsCard = ({ well?: IWell isLoading?: boolean }) => { + const releaseStatus: ReleaseStatus = + well?.current_location?.release_status ?? 'draft' + + const ownerPublicDataAcknowledgement = + getPublicDataAcknowledgementStatus(releaseStatus) + + const permissions = well?.permissions ?? [] + if (isLoading) { return ( @@ -53,6 +65,7 @@ export const OwnerPermissionsCard = ({ + ) @@ -65,15 +78,33 @@ export const OwnerPermissionsCard = ({ Owner Permissions + + + Owner acknowledged public data release + + + - {!well?.permissions || well.permissions.length === 0 ? ( + {permissions.length === 0 ? ( - N/A + No permissions available ) : ( - {well.permissions.map((p, i) => ( - + {permissions.map((permission, index) => ( + - {p.permission_type}: + {permission.permission_type}: - + - {(p.start_date || p.end_date) && ( + {(permission.start_date || permission.end_date) && ( )} - {i < well.permissions.length - 1 && } + {index < well.permissions.length - 1 && ( + + )} ))} @@ -155,3 +171,45 @@ const DateMeta = ({ label, value }: { label: string; value: string }) => ( ) + +const getPublicDataAcknowledgementStatus = ( + releaseStatus: ReleaseStatus +): boolean | null => { + if (releaseStatus === 'public') return true + if (releaseStatus === 'private') return false + return null +} + +const getBooleanStatusLabel = ( + value: boolean | null, + variant: 'permission' | 'yesno' = 'permission' +) => { + if (variant === 'yesno') { + return value === true ? 'Yes' : value === false ? 'No' : 'Unknown' + } + + return value === true + ? 'Allowed' + : value === false + ? 'Not Allowed' + : 'Unknown' +} + +const getBooleanStatusColor = (value: boolean | null) => { + return value === true ? 'success' : value === false ? 'error' : 'default' +} + +const BooleanStatusChip = ({ + value, + variant = 'permission', +}: { + value: boolean | null + variant?: 'permission' | 'yesno' +}) => ( + +) From 0f5ee620767436818bad84747f10bb652f3195de Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Tue, 28 Apr 2026 15:44:35 -0400 Subject: [PATCH 12/28] Add shared well list column titles for the grid and map CSV. --- src/well-list/wellListColumnLabels.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 src/well-list/wellListColumnLabels.ts diff --git a/src/well-list/wellListColumnLabels.ts b/src/well-list/wellListColumnLabels.ts new file mode 100644 index 00000000..e37ac51f --- /dev/null +++ b/src/well-list/wellListColumnLabels.ts @@ -0,0 +1,24 @@ +/** + * Column titles for the Ocotillo water well list (`thing/list.tsx` DataGrid). + * Map visible-layer CSV uses the same strings for the same fields so exports match the grid. + */ +export const WellListColumnLabels = { + wellId: 'Well ID', + name: 'Name', + siteName: 'Site name', + monitoring: 'Monitoring', + createdAt: 'Created At', + wellStatus: 'Well Status', + type: 'Type', + aquifers: 'Aquifers', + releaseStatus: 'Release Status', + wellDepthFt: 'Well Depth (ft)', + holeDepthFt: 'Hole Depth (ft)', + firstVisit: 'First Visit', + contacts: 'Contacts', + completed: 'Completed', + driller: 'Driller', + latitude: 'Latitude', + longitude: 'Longitude', + alternateIds: 'Alternate IDs', +} as const From 37a0a9a00967940361896a185825dca2144d61dc Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Tue, 28 Apr 2026 15:44:37 -0400 Subject: [PATCH 13/28] Add helpers for absolute well show URLs used in map CSV export. --- src/utils/wellPublicUrls.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 src/utils/wellPublicUrls.ts diff --git a/src/utils/wellPublicUrls.ts b/src/utils/wellPublicUrls.ts new file mode 100644 index 00000000..493bd93b --- /dev/null +++ b/src/utils/wellPublicUrls.ts @@ -0,0 +1,11 @@ +/** Public web app origin used for deep links in exports (defaults to production). */ +export function getOcotilloPublicAppOrigin(): string { + const raw = import.meta.env.VITE_OCOTILLO_PUBLIC_APP_URL as string | undefined + const trimmed = raw?.trim().replace(/\/$/, '') + return trimmed || 'https://ocotillo.newmexicowaterdata.org' +} + +/** Canonical Ocotillo well detail URL for field workflows (production unless overridden). */ +export function buildWellShowAbsoluteUrl(thingId: string | number): string { + return `${getOcotilloPublicAppOrigin()}/ocotillo/well/show/${thingId}` +} From 89afe7ae647098465a7d6f4b4ad7e37ab21948c3 Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Tue, 28 Apr 2026 15:44:41 -0400 Subject: [PATCH 14/28] Add well map CSV export labels and row builder aligned with the wells list. --- src/well-export/wellMapCsvExport.ts | 436 ++++++++++++++++++++++++++++ 1 file changed, 436 insertions(+) create mode 100644 src/well-export/wellMapCsvExport.ts diff --git a/src/well-export/wellMapCsvExport.ts b/src/well-export/wellMapCsvExport.ts new file mode 100644 index 00000000..39117529 --- /dev/null +++ b/src/well-export/wellMapCsvExport.ts @@ -0,0 +1,436 @@ +/** + * Map visible-layer CSV for water wells: column titles match the Wells list where the same + * field exists (`WellListColumnLabels`). Extra export-only headers use `WellMapCsvOnlyLabels`. + */ +import type { IWellDetails } from '@/interfaces/ocotillo' +import type { IWell } from '@/interfaces/ocotillo' +import type { IContact } from '@/interfaces/ocotillo' +import { formatAppDate } from '@/utils' +import { buildWellShowAbsoluteUrl } from '@/utils/wellPublicUrls' +import { WellListColumnLabels } from '@/well-list/wellListColumnLabels' + +/** Map CSV columns that are not on the Wells list grid (or differ in shape, for example contact columns). */ +export const WellMapCsvOnlyLabels = { + wellDetailPage: 'Well detail page', + enrichmentFailed: 'Enrichment failed', + measuringPoint: 'Measuring Point', + contactName: 'Contact name', + email: 'Email', + phone: 'Phone', + monitoringFrequency: 'Monitoring Frequency', + measuredFor: 'Measured For', + lastVisitDate: 'Last Visit Date', + firstVisitStaff: 'First Visit Staff', + county: 'County', + state: 'State', + quadName: 'Quad name', + groups: 'Groups', + wellPurposes: 'Well purposes', + casingDiameter: 'Casing Diameter', + casingDepth: 'Casing Depth', + casingMaterials: 'Casing Materials', + pumpType: 'Pump Type', + pumpDepth: 'Pump Depth', + elevation: 'Elevation', + elevationMethod: 'Elevation Method', + verticalDatum: 'Vertical Datum', + dataloggerSuitability: 'Datalogger Suitability', + depthSource: 'Depth Source', + historicDepthToWater: 'Historic Depth to Water', + constructionMethod: 'Construction Method', + formationCompletionCode: 'Formation Completion Code', + aquiferTypes: 'Aquifer types', + measuringPointHeight: 'Measuring point height', + measuringPointDescription: 'Measuring point description', + utmZone: 'UTM zone', + utmEasting: 'UTM easting', + utmNorthing: 'UTM northing', + locationNotes: 'Location notes', + generalNotes: 'General notes', + siteNotes: 'Site notes', + constructionNotes: 'Construction notes', + waterNotes: 'Water notes', + permissions: 'Permissions', + fullWellJson: 'Full well JSON', +} as const + +/** OGC / map feature keys omitted from CSV (IDs and layer name duplicate other fields). */ +export const MAP_CSV_DROPPED_FEATURE_KEYS = [ + 'name', + 'id', + 'thing_name', + 'thing_id', + 'well_id', +] as const + +/** Reserved for optional leading columns from the map layer; empty means none. */ +export const MAP_CSV_OGC_LEADING_KEYS: readonly string[] = [] + +export const WELL_MAP_CSV_COLUMN_HEADERS_EXPLICIT: readonly string[] = [ + WellListColumnLabels.wellId, + WellListColumnLabels.name, + WellMapCsvOnlyLabels.wellDetailPage, + WellListColumnLabels.siteName, + WellListColumnLabels.holeDepthFt, + WellListColumnLabels.wellDepthFt, + WellMapCsvOnlyLabels.measuringPoint, + WellMapCsvOnlyLabels.contactName, + WellMapCsvOnlyLabels.email, + WellMapCsvOnlyLabels.phone, + WellListColumnLabels.wellStatus, + WellListColumnLabels.monitoring, + WellListColumnLabels.createdAt, + WellListColumnLabels.type, + WellListColumnLabels.aquifers, + WellListColumnLabels.releaseStatus, + WellMapCsvOnlyLabels.monitoringFrequency, + WellMapCsvOnlyLabels.measuredFor, + WellMapCsvOnlyLabels.lastVisitDate, + WellListColumnLabels.firstVisit, + WellMapCsvOnlyLabels.firstVisitStaff, + WellMapCsvOnlyLabels.county, + WellMapCsvOnlyLabels.state, + WellMapCsvOnlyLabels.quadName, + WellListColumnLabels.alternateIds, + WellMapCsvOnlyLabels.groups, + WellMapCsvOnlyLabels.wellPurposes, + WellMapCsvOnlyLabels.casingDiameter, + WellMapCsvOnlyLabels.casingDepth, + WellMapCsvOnlyLabels.casingMaterials, + WellMapCsvOnlyLabels.pumpType, + WellMapCsvOnlyLabels.pumpDepth, + WellMapCsvOnlyLabels.elevation, + WellMapCsvOnlyLabels.elevationMethod, + WellMapCsvOnlyLabels.verticalDatum, + WellMapCsvOnlyLabels.dataloggerSuitability, + WellListColumnLabels.driller, + WellListColumnLabels.latitude, + WellListColumnLabels.longitude, + WellMapCsvOnlyLabels.depthSource, + WellMapCsvOnlyLabels.historicDepthToWater, + WellListColumnLabels.completed, + WellMapCsvOnlyLabels.constructionMethod, + WellMapCsvOnlyLabels.measuringPointHeight, + WellMapCsvOnlyLabels.measuringPointDescription, + WellMapCsvOnlyLabels.formationCompletionCode, + WellMapCsvOnlyLabels.aquiferTypes, + WellMapCsvOnlyLabels.locationNotes, + WellMapCsvOnlyLabels.generalNotes, + WellMapCsvOnlyLabels.siteNotes, + WellMapCsvOnlyLabels.constructionNotes, + WellMapCsvOnlyLabels.waterNotes, + WellMapCsvOnlyLabels.permissions, + WellMapCsvOnlyLabels.utmZone, + WellMapCsvOnlyLabels.utmEasting, + WellMapCsvOnlyLabels.utmNorthing, + WellMapCsvOnlyLabels.fullWellJson, +] + +/** Full preferred CSV column order for visible features export (OGC keys, then well columns). */ +export function buildMapExportPreferredColumnOrder(): string[] { + return [...MAP_CSV_OGC_LEADING_KEYS, ...WELL_MAP_CSV_COLUMN_HEADERS_EXPLICIT] +} + +/** + * Site name for CSV: API `site_name`, else OGC map `site_name`, else text from + * Unknown-organization alternate IDs (same strings users used to read from "Alternate IDs"). + */ +export function deriveSiteNameColumn( + well: IWell, + ogcSiteName?: string | number | null +): string { + const fromApi = well.site_name?.trim() + if (fromApi) return fromApi + if (ogcSiteName != null && String(ogcSiteName).trim() !== '') { + return String(ogcSiteName).trim() + } + const unknownLabels = (well.alternate_ids ?? []) + .filter( + (link) => + (link.alternate_organization || '').toUpperCase().trim() === 'UNKNOWN' + ) + .map((link) => link.alternate_id?.trim()) + .filter(Boolean) as string[] + if (unknownLabels.length) return unknownLabels.join(' ; ') + return '' +} + +/** Remove legacy `detail_*` keys from older exports or cached features. */ +export function stripLegacyDetailPrefixedKeys( + properties: Record +): Record { + return Object.fromEntries( + Object.entries(properties).filter(([k]) => !k.startsWith('detail_')) + ) +} + +export function dropMapCsvExcludedFeatureKeys( + properties: Record +): Record { + const out = { ...properties } + for (const k of MAP_CSV_DROPPED_FEATURE_KEYS) { + delete out[k] + } + return out +} + +function formatMeasuringPointLikeCoreCard(well: IWell): string { + const parts = [ + well?.measuring_point_description?.trim() || null, + well?.measuring_point_height != null + ? `${well.measuring_point_height} ${well.measuring_point_height_unit ?? ''}`.trim() + : null, + ].filter(Boolean) + return parts.join(' | ') +} + +function measuredForLabel(firstVisitDate: string | null | undefined): string { + if (!firstVisitDate) return '' + const start = new Date(firstVisitDate) + if (Number.isNaN(start.getTime())) return '' + const now = new Date() + const totalMonths = + (now.getFullYear() - start.getFullYear()) * 12 + + (now.getMonth() - start.getMonth()) + if (totalMonths <= 0) return 'Less than a month' + const years = Math.floor(totalMonths / 12) + const months = totalMonths % 12 + if (years === 0) return `${months} month${months !== 1 ? 's' : ''}` + if (months === 0) return `${years} year${years !== 1 ? 's' : ''}` + return `${years} year${years !== 1 ? 's' : ''}, ${months} month${months !== 1 ? 's' : ''}` +} + +function formatMonitoringFrequencies(well: IWell): string { + const freqs = well.monitoring_frequencies ?? [] + if (freqs.length === 0) return '' + return freqs + .map((f) => { + const end = f.end_date ? ` - ${formatAppDate(f.end_date)}` : '' + return `${f.monitoring_frequency} ${formatAppDate(f.start_date)}${end}` + }) + .join(' ; ') +} + +function formatFirstVisitStaff(details: IWellDetails): string { + const parts = + details.first_field_event?.field_event_participants?.map((p) => { + const n = p.participant?.name || 'Unknown' + return p.participant_role ? `${n} (${p.participant_role})` : n + }) ?? [] + return parts.join(', ') +} + +function formatContactTriple(contacts: IContact[] | undefined): Record< + string, + string +> { + if (!contacts?.length) { + return { + [WellMapCsvOnlyLabels.contactName]: '', + [WellMapCsvOnlyLabels.email]: '', + [WellMapCsvOnlyLabels.phone]: '', + } + } + + const names = contacts + .map((c) => c.name?.trim() ?? '') + .filter(Boolean) + .join(', ') + + const emails = contacts + .map((c) => + (c.emails ?? []) + .map((e) => e.email?.trim()) + .filter(Boolean) + .join(', ') + ) + .filter(Boolean) + .join(', ') + + const phones = contacts + .map((c) => + (c.phones ?? []) + .map((p) => p.phone_number?.trim()) + .filter(Boolean) + .join(', ') + ) + .filter(Boolean) + .join(', ') + + return { + [WellMapCsvOnlyLabels.contactName]: names, + [WellMapCsvOnlyLabels.email]: emails, + [WellMapCsvOnlyLabels.phone]: phones, + } +} + +function stringifyNotes(well: IWell, key: keyof IWell): string { + const notes = well[key] + if (!Array.isArray(notes)) return '' + return notes + .map((n: { content?: string }) => n?.content) + .filter(Boolean) + .join(' | ') +} + +export type WellMapCsvBuildOptions = { + /** OGC / map feature `site_name` when API `site_name` is null */ + ogcSiteName?: string | number | null +} + +/** Values for one well row: keys are CSV headers. List-aligned titles use `WellListColumnLabels`. */ +export function buildWellMapCsvValues( + details: IWellDetails, + options?: WellMapCsvBuildOptions +): Record { + const { well, contacts } = details + const locProps = well.current_location?.properties as + | Record + | undefined + + const elevationNum = + typeof locProps?.elevation === 'number' ? locProps.elevation : null + const utm = locProps?.utm_coordinates as Record | undefined + + const lastVisit = details.field_events?.[0]?.event_date + + const holeDisplay = well.hole_depth + ? `${well.hole_depth} ${well.hole_depth_unit ?? ''}`.trim() + : '' + const wellDepthDisplay = well.well_depth + ? `${well.well_depth} ${well.well_depth_unit ?? ''}`.trim() + : '' + + const casingDiam = + well.well_casing_diameter != null + ? `${well.well_casing_diameter} ${well.well_casing_diameter_unit ?? ''}`.trim() + : '' + const casingDepth = + well.well_casing_depth != null + ? `${well.well_casing_depth} ${well.well_casing_depth_unit ?? ''}`.trim() + : '' + const pumpDepth = + well.well_pump_depth != null + ? `${well.well_pump_depth} ${well.well_pump_depth_unit ?? ''}`.trim() + : '' + + const elevDisplay = + elevationNum != null + ? `${elevationNum.toFixed(2)} ${String(locProps?.elevation_unit ?? '')}`.trim() + : '' + + const dataloggerVal = + (well as IWell & { datalogger_suitability_status?: string | null }) + .datalogger_suitability_status ?? + (well.is_suitable_for_datalogger != null + ? String(well.is_suitable_for_datalogger) + : '') + + const row: Record = { + [WellListColumnLabels.wellId]: String(well.id), + [WellListColumnLabels.name]: well.name ?? '', + [WellMapCsvOnlyLabels.wellDetailPage]: buildWellShowAbsoluteUrl(well.id), + [WellListColumnLabels.siteName]: deriveSiteNameColumn(well, options?.ogcSiteName), + [WellListColumnLabels.holeDepthFt]: holeDisplay, + [WellListColumnLabels.wellDepthFt]: wellDepthDisplay, + [WellMapCsvOnlyLabels.measuringPoint]: formatMeasuringPointLikeCoreCard(well), + ...formatContactTriple(contacts), + [WellListColumnLabels.wellStatus]: well.well_status ?? '', + [WellListColumnLabels.monitoring]: well.monitoring_status ?? '', + [WellListColumnLabels.createdAt]: formatAppDate(well.created_at) || '', + [WellListColumnLabels.type]: well.thing_type ?? '', + [WellListColumnLabels.aquifers]: (well.aquifers ?? []) + .map((a) => a.aquifer_system) + .filter(Boolean) + .join(', '), + [WellListColumnLabels.releaseStatus]: String(well.release_status ?? ''), + [WellMapCsvOnlyLabels.monitoringFrequency]: formatMonitoringFrequencies(well), + [WellMapCsvOnlyLabels.measuredFor]: measuredForLabel(well.first_visit_date), + [WellMapCsvOnlyLabels.lastVisitDate]: formatAppDate(lastVisit) || '', + [WellListColumnLabels.firstVisit]: formatAppDate(well.first_visit_date) || '', + [WellMapCsvOnlyLabels.firstVisitStaff]: formatFirstVisitStaff(details), + [WellMapCsvOnlyLabels.county]: String(locProps?.county ?? ''), + [WellMapCsvOnlyLabels.state]: String(locProps?.state ?? ''), + [WellMapCsvOnlyLabels.quadName]: String(locProps?.quad_name ?? ''), + [WellListColumnLabels.alternateIds]: (well.alternate_ids ?? []) + .map( + (link) => + `${link.alternate_organization ?? ''}: ${link.alternate_id ?? ''}` + ) + .join(', '), + [WellMapCsvOnlyLabels.groups]: (well.groups ?? []) + .map((g) => g.name) + .filter(Boolean) + .join('; '), + [WellMapCsvOnlyLabels.wellPurposes]: (well.well_purposes ?? []).join('; '), + [WellMapCsvOnlyLabels.casingDiameter]: casingDiam, + [WellMapCsvOnlyLabels.casingDepth]: casingDepth, + [WellMapCsvOnlyLabels.casingMaterials]: (well.well_casing_materials ?? []).join( + ', ' + ), + [WellMapCsvOnlyLabels.pumpType]: well.well_pump_type ?? '', + [WellMapCsvOnlyLabels.pumpDepth]: pumpDepth, + [WellMapCsvOnlyLabels.elevation]: elevDisplay, + [WellMapCsvOnlyLabels.elevationMethod]: String(locProps?.elevation_method ?? ''), + [WellMapCsvOnlyLabels.verticalDatum]: String(locProps?.vertical_datum ?? ''), + [WellMapCsvOnlyLabels.dataloggerSuitability]: dataloggerVal, + [WellListColumnLabels.driller]: well.well_driller_name ?? '', + [WellListColumnLabels.latitude]: + well.current_location?.geometry?.coordinates?.[1] != null + ? String(well.current_location.geometry.coordinates[1]) + : '', + [WellListColumnLabels.longitude]: + well.current_location?.geometry?.coordinates?.[0] != null + ? String(well.current_location.geometry.coordinates[0]) + : '', + [WellMapCsvOnlyLabels.depthSource]: well.well_depth_source ?? '', + [WellMapCsvOnlyLabels.historicDepthToWater]: + (well.historic_depth_to_water?.length ?? 0) > 0 + ? (well.historic_depth_to_water ?? []).join(', ') + : '', + [WellListColumnLabels.completed]: + formatAppDate(well.well_completion_date) || '', + [WellMapCsvOnlyLabels.constructionMethod]: well.well_construction_method ?? '', + [WellMapCsvOnlyLabels.measuringPointHeight]: String( + well.measuring_point_height ?? '' + ), + [WellMapCsvOnlyLabels.measuringPointDescription]: + well.measuring_point_description ?? '', + [WellMapCsvOnlyLabels.formationCompletionCode]: + well.formation_completion_code ?? '', + [WellMapCsvOnlyLabels.aquiferTypes]: + well.aquifers && well.aquifers.length > 0 + ? [...new Set(well.aquifers.flatMap((a) => a.aquifer_types))].join(', ') + : '', + [WellMapCsvOnlyLabels.locationNotes]: String(locProps?.nma_location_notes ?? ''), + [WellMapCsvOnlyLabels.generalNotes]: stringifyNotes(well, 'general_notes'), + [WellMapCsvOnlyLabels.siteNotes]: stringifyNotes(well, 'site_notes'), + [WellMapCsvOnlyLabels.constructionNotes]: stringifyNotes(well, 'construction_notes'), + [WellMapCsvOnlyLabels.waterNotes]: stringifyNotes(well, 'water_notes'), + [WellMapCsvOnlyLabels.permissions]: JSON.stringify(well.permissions ?? []), + [WellMapCsvOnlyLabels.utmZone]: utm?.utm_zone != null ? String(utm.utm_zone) : '', + [WellMapCsvOnlyLabels.utmEasting]: + utm?.easting != null ? String(utm.easting) : '', + [WellMapCsvOnlyLabels.utmNorthing]: + utm?.northing != null ? String(utm.northing) : '', + [WellMapCsvOnlyLabels.fullWellJson]: JSON.stringify(well), + } + + return row +} + +export function buildWellMapCsvEnrichmentFailedValues( + thingId: string, + options?: WellMapCsvBuildOptions +): Record { + const fromOgc = + options?.ogcSiteName != null && String(options.ogcSiteName).trim() !== '' + ? String(options.ogcSiteName).trim() + : '' + return { + [WellListColumnLabels.wellId]: thingId, + [WellMapCsvOnlyLabels.wellDetailPage]: buildWellShowAbsoluteUrl(thingId), + [WellMapCsvOnlyLabels.enrichmentFailed]: 'Yes', + [WellListColumnLabels.siteName]: fromOgc, + } +} From de510a7fc2ca81516bba4fc69aff02549d1ccad6 Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Tue, 28 Apr 2026 15:44:44 -0400 Subject: [PATCH 15/28] Support preferred property column order when building layer CSV output. --- src/utils/layerExport.ts | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/src/utils/layerExport.ts b/src/utils/layerExport.ts index a41e6487..14d66758 100644 --- a/src/utils/layerExport.ts +++ b/src/utils/layerExport.ts @@ -79,8 +79,19 @@ export const filterLayerFeaturesBySelection = ( }) } -export const buildLayerCsv = (features: any[]): string => { - const propertyKeys = [ +export type BuildLayerCsvOptions = { + /** + * Property keys to emit first when present; remaining keys are sorted alphabetically. + * Used so map exports match the preferred Wells list / map CSV column order. + */ + preferredPropertyColumnOrder?: string[] +} + +export const buildLayerCsv = ( + features: any[], + options?: BuildLayerCsvOptions +): string => { + const allPropertyKeys = [ ...new Set( features.flatMap((feature: any) => (Object.keys(feature?.properties || {}) as string[]).filter( @@ -88,7 +99,17 @@ export const buildLayerCsv = (features: any[]): string => { ) ) ), - ].sort() + ] + + const preferred = (options?.preferredPropertyColumnOrder ?? []).filter((key) => + allPropertyKeys.includes(key) + ) + const preferredSet = new Set(preferred) + const rest = allPropertyKeys + .filter((key) => !preferredSet.has(key)) + .sort() + + const propertyKeys = [...preferred, ...rest] const headers: string[] = [ ...propertyKeys, From d3cd967125fde92497be6a020a3c392ee3ef6e23 Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Tue, 28 Apr 2026 15:44:47 -0400 Subject: [PATCH 16/28] Merge fetched well details into map features for enriched CSV export. --- src/utils/wellMapExport.ts | 95 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 src/utils/wellMapExport.ts diff --git a/src/utils/wellMapExport.ts b/src/utils/wellMapExport.ts new file mode 100644 index 00000000..304330e6 --- /dev/null +++ b/src/utils/wellMapExport.ts @@ -0,0 +1,95 @@ +import type { IWellDetails } from '@/interfaces/ocotillo' +import { getFeatureId } from '@/utils/mapSelection' +import { + buildWellMapCsvEnrichmentFailedValues, + buildWellMapCsvValues, + dropMapCsvExcludedFeatureKeys, + stripLegacyDetailPrefixedKeys, +} from '@/well-export/wellMapCsvExport' + +export { buildWellShowAbsoluteUrl, getOcotilloPublicAppOrigin } from '@/utils/wellPublicUrls' + +type CustomGetter = (args: { + url: string + method: string +}) => Promise<{ data: unknown }> + +/** + * @deprecated use buildWellMapCsvValues from @/well-export/wellMapCsvExport + */ +export function flattenWellDetailsForCsv(details: IWellDetails): Record { + return buildWellMapCsvValues(details) +} + +const DETAIL_FETCH_CONCURRENCY = 8 + +/** + * For map-export features with a numeric thing id, fetch Ocotillo well details and merge + * flattened columns into each feature's properties. + */ +export async function enrichMapFeaturesWithWellDetails( + features: any[], + customRequest: CustomGetter +): Promise { + const uniqueIds = [ + ...new Set( + features + .map((f) => getFeatureId(f)) + .filter((id): id is string => Boolean(id) && /^\d+$/.test(id)) + ), + ] + + const idToDetails = new Map() + + for (let i = 0; i < uniqueIds.length; i += DETAIL_FETCH_CONCURRENCY) { + const batch = uniqueIds.slice(i, i + DETAIL_FETCH_CONCURRENCY) + await Promise.all( + batch.map(async (id) => { + try { + const response = await customRequest({ + url: `thing/water-well/${id}/details`, + method: 'get', + }) + const data = response.data as IWellDetails + idToDetails.set(id, data) + } catch { + idToDetails.set(id, 'fail') + } + }) + ) + } + + return features.map((feature) => { + const id = getFeatureId(feature) + const raw = (feature.properties || {}) as Record + const cleaned = stripLegacyDetailPrefixedKeys(raw) + const ogcSiteName = cleaned.site_name + const { site_name: _dropOgcSite, ...restNoOgcSite } = cleaned + const baseProps = dropMapCsvExcludedFeatureKeys(restNoOgcSite) + + if (!id || !/^\d+$/.test(id)) { + return { ...feature, properties: baseProps } + } + + const det = idToDetails.get(id) + if (det === 'fail') { + return { + ...feature, + properties: { + ...baseProps, + ...buildWellMapCsvEnrichmentFailedValues(id, { ogcSiteName }), + }, + } + } + if (det) { + return { + ...feature, + properties: { + ...baseProps, + ...buildWellMapCsvValues(det, { ogcSiteName }), + }, + } + } + return { ...feature, properties: baseProps } + }) +} From b9dcaa6c8c6682d7ef31b524024d79a7b7fdf6f9 Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Tue, 28 Apr 2026 15:44:49 -0400 Subject: [PATCH 17/28] Use well list column label constants for Ocotillo water well grid headers. --- src/pages/ocotillo/thing/list.tsx | 35 ++++++++++++++++--------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/src/pages/ocotillo/thing/list.tsx b/src/pages/ocotillo/thing/list.tsx index 7469ce87..b29c4f7b 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 { WellListColumnLabels } from '@/well-list/wellListColumnLabels' export const SpringList: React.FC = () => { const { dataGridProps } = useDataGrid({ @@ -115,7 +116,7 @@ export const WellList: React.FC = () => { () => [ { field: 'name', - headerName: 'Name', + headerName: WellListColumnLabels.name, description: 'Official well identifier used in bureau records (for example county prefix and local ID).', type: 'string', @@ -124,7 +125,7 @@ export const WellList: React.FC = () => { }, { field: 'site_name', - headerName: 'Site name', + headerName: WellListColumnLabels.siteName, description: 'Name of the monitoring site or facility associated with this well when one is recorded (NMBGMR alternate ID when present).', type: 'string', @@ -134,7 +135,7 @@ export const WellList: React.FC = () => { }, { field: 'monitoring_status', - headerName: 'Monitoring', + headerName: WellListColumnLabels.monitoring, description: 'Whether the well is actively monitored or how monitoring is categorized in the current record.', type: 'string', @@ -142,7 +143,7 @@ export const WellList: React.FC = () => { }, { field: 'created_at', - headerName: 'Created At', + headerName: WellListColumnLabels.createdAt, description: 'Calendar date when this well record was first added to Ocotillo.', width: 130, @@ -150,14 +151,14 @@ export const WellList: React.FC = () => { }, { field: 'well_status', - headerName: 'Well Status', + headerName: WellListColumnLabels.wellStatus, description: 'Operational or administrative status of the well.', type: 'string', width: 150, }, { field: 'thing_type', - headerName: 'Type', + headerName: WellListColumnLabels.type, description: 'Infrastructure type from the controlled vocabulary (for example water well or geothermal well).', type: 'string', @@ -165,7 +166,7 @@ export const WellList: React.FC = () => { }, { field: 'aquifers', - headerName: 'Aquifers', + headerName: WellListColumnLabels.aquifers, description: 'Aquifer systems linked to this well, summarized from association data. Sort uses the first aquifer name alphabetically among linked systems.', minWidth: 180, @@ -180,7 +181,7 @@ export const WellList: React.FC = () => { }, { field: 'release_status', - headerName: 'Release Status', + headerName: WellListColumnLabels.releaseStatus, description: 'Whether the record is released for public viewing under data release rules.', type: 'string', @@ -188,7 +189,7 @@ export const WellList: React.FC = () => { }, { field: 'well_depth', - headerName: 'Well Depth (ft)', + headerName: WellListColumnLabels.wellDepthFt, description: 'Completed well depth from ground surface to bottom of the well in feet.', type: 'number', @@ -198,7 +199,7 @@ export const WellList: React.FC = () => { }, { field: 'hole_depth', - headerName: 'Hole Depth (ft)', + headerName: WellListColumnLabels.holeDepthFt, description: 'Total drilled hole depth from ground surface to bottom of the borehole in feet.', type: 'number', @@ -208,7 +209,7 @@ export const WellList: React.FC = () => { }, { field: 'first_visit_date', - headerName: 'First Visit', + headerName: WellListColumnLabels.firstVisit, description: 'Date of the bureau first recorded visit to this well when available.', width: 130, @@ -216,7 +217,7 @@ export const WellList: React.FC = () => { }, { field: 'contacts', - headerName: 'Contacts', + headerName: WellListColumnLabels.contacts, description: 'People or organizations linked to this well; open a contact from the link. Sort uses the alphabetically first linked contact name.', minWidth: 180, @@ -256,14 +257,14 @@ export const WellList: React.FC = () => { }, { field: 'well_completion_date', - headerName: 'Completed', + headerName: WellListColumnLabels.completed, description: 'Reported date the well construction was completed.', width: 130, valueGetter: (v: string) => formatAppDate(v), }, { field: 'well_driller_name', - headerName: 'Driller', + headerName: WellListColumnLabels.driller, description: 'Drilling company name when it was recorded for this well.', type: 'string', minWidth: 150, @@ -271,7 +272,7 @@ export const WellList: React.FC = () => { }, { field: 'latitude', - headerName: 'Latitude', + headerName: WellListColumnLabels.latitude, description: 'Latitude of the current mapped location in decimal degrees (WGS84).', type: 'number', @@ -284,7 +285,7 @@ export const WellList: React.FC = () => { }, { field: 'longitude', - headerName: 'Longitude', + headerName: WellListColumnLabels.longitude, description: 'Longitude of the current mapped location in decimal degrees (WGS84).', type: 'number', @@ -297,7 +298,7 @@ export const WellList: React.FC = () => { }, { field: 'alternate_ids', - headerName: 'Alternate IDs', + headerName: WellListColumnLabels.alternateIds, description: 'Identifiers from other agencies or programs that cross reference this well.', minWidth: 160, From 8985b0c6c5b8188e9cdee9c54ee44088211353f1 Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Tue, 28 Apr 2026 15:44:52 -0400 Subject: [PATCH 18/28] Use map CSV column order and well detail enrichment for visible layer export. --- src/pages/ocotillo/map/list.tsx | 56 +++++++++++++++++++++++---------- 1 file changed, 39 insertions(+), 17 deletions(-) diff --git a/src/pages/ocotillo/map/list.tsx b/src/pages/ocotillo/map/list.tsx index 7a58f288..f9c0c587 100644 --- a/src/pages/ocotillo/map/list.tsx +++ b/src/pages/ocotillo/map/list.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react' import { captureEvent } from '@/analytics/posthog' import { Layer, Source } from 'react-map-gl' -import { useGo } from '@refinedev/core' +import { useDataProvider, useGo } from '@refinedev/core' import { useLocation } from 'react-router' import { Box, @@ -44,6 +44,8 @@ import { filterLayerFeaturesBySelection, sanitizeLayerExportFilename, } from '@/utils/layerExport' +import { enrichMapFeaturesWithWellDetails } from '@/utils/wellMapExport' +import { buildMapExportPreferredColumnOrder } from '@/well-export/wellMapCsvExport' import { getSelectedPointColumnLabel, getFeatureId, @@ -227,6 +229,9 @@ export const MapView: React.FC = () => { captureEvent('feature_used', { feature: 'map' }) }, []) + const dataProvider = useDataProvider() + const [exportVisibleBusy, setExportVisibleBusy] = useState(false) + const location = useLocation() const mapContainerRef = useRef(null) const mapRef = useRef(null) @@ -544,7 +549,9 @@ export const MapView: React.FC = () => { } downloadLayerBlob( - buildLayerCsv(features), + buildLayerCsv(features, { + preferredPropertyColumnOrder: buildMapExportPreferredColumnOrder(), + }), 'text/csv;charset=utf-8;', 'csv', label, @@ -552,18 +559,32 @@ export const MapView: React.FC = () => { ) } - const onExportVisiblePoints = () => { - visiblePointFeaturesByLayer.forEach(({ label, features }, index) => { - exportLayerCollection( - { - type: 'FeatureCollection', + const onExportVisiblePoints = async () => { + setExportVisibleBusy(true) + try { + const ocotillo = dataProvider('ocotillo') + const customRequest = (args: { url: string; method: string }) => + ocotillo.custom(args) + + for (let index = 0; index < visiblePointFeaturesByLayer.length; index++) { + const { label, features } = visiblePointFeaturesByLayer[index] + const enriched = await enrichMapFeaturesWithWellDetails( features, - }, - label, - exportFormat, - index - ) - }) + customRequest + ) + exportLayerCollection( + { + type: 'FeatureCollection', + features: enriched, + }, + label, + exportFormat, + index + ) + } + } finally { + setExportVisibleBusy(false) + } } const onExportLayer = () => { @@ -1536,12 +1557,13 @@ export const MapView: React.FC = () => { { + void onExportVisiblePoints() + }} buttonLabel="Export Visible" selectorWidth={142} - tooltip={`Click to download the visible features for each active dataset as separate ${ - exportFormat === 'csv' ? 'CSV' : 'GeoJSON' - } files.`} + disabled={exportVisibleBusy} + tooltip="Downloads one file per visible dataset. CSV and GeoJSON merge OGC properties with Ocotillo well details (full well JSON, contacts, location, and a public well detail link). Fetches the details API for each visible well id (batched)." /> {`${paginatedVisibleFeatureGroups.start + 1}-${paginatedVisibleFeatureGroups.end} of ${paginatedVisibleFeatureGroups.total}`} From 668900aa266a0e19bf8656253d75e96bff318cc3 Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Tue, 28 Apr 2026 15:44:55 -0400 Subject: [PATCH 19/28] Use plain text captions for contact name, email, and phone on well detail. --- src/components/WellShow/Contacts.tsx | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/components/WellShow/Contacts.tsx b/src/components/WellShow/Contacts.tsx index 3c6f0aa0..9b66d172 100644 --- a/src/components/WellShow/Contacts.tsx +++ b/src/components/WellShow/Contacts.tsx @@ -47,6 +47,15 @@ const ContactBlock = ({ contact }: { contact: IContact }) => { {roleType} )} + {contact.name && ( + + Contact name + + )} {contact.name && contact.id && ( { {contact.organization || 'No organization listed'} + {emails.length > 0 && ( + + Email + + )} {emails.map((email, idx) => ( { {email} ))} + {phones.length > 0 && ( + + Phone + + )} {phones.map((phone, idx) => ( Date: Tue, 28 Apr 2026 15:44:58 -0400 Subject: [PATCH 20/28] Document optional VITE_OCOTILLO_PUBLIC_APP_URL for well links in map CSV. --- .env.production.example | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.env.production.example b/.env.production.example index 21089cc3..361965e0 100644 --- a/.env.production.example +++ b/.env.production.example @@ -10,6 +10,9 @@ VITE_POSTHOG_HOST=https://us.i.posthog.com VITE_APP_ENV=production VITE_APP_TIMEZONE=America/Denver +# Optional: base URL for well detail links in map CSV export (defaults to https://ocotillo.newmexicowaterdata.org) +# VITE_OCOTILLO_PUBLIC_APP_URL=https://ocotillo.newmexicowaterdata.org + VITE_AUTHENTIK_CLIENT_ID= VITE_AUTHENTIK_URL= VITE_AUTHENTIK_REDIRECT_URI= From 5b4b78afd5b6334f1169db30ea667837cc0ad1c3 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Tue, 28 Apr 2026 14:57:01 -0500 Subject: [PATCH 21/28] feat(pages/content): Update content manager to accept ENVs & code element --- src/App.tsx | 4 ++ src/components/layout/sider.tsx | 1 + src/pages/content/index.tsx | 116 ++++++++++++++++++++++++++++++-- 3 files changed, 114 insertions(+), 7 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 6673c1f7..d2d4a7c3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -67,6 +67,10 @@ const App: React.FC = () => ( path="/report-a-bug" element={} /> + } + /> } /> } /> } /> diff --git a/src/components/layout/sider.tsx b/src/components/layout/sider.tsx index 039669d2..85160060 100644 --- a/src/components/layout/sider.tsx +++ b/src/components/layout/sider.tsx @@ -418,6 +418,7 @@ export const ThemedSiderV2: React.FC = ({ { to: '/about', label: 'About' }, { to: '/ocotillo/help', label: 'Connect Desktop GIS' }, { to: '/report-a-bug', label: 'Report a Bug' }, + { to: '/ogcapi', label: 'Connect Desktop GIS (md)' }, ].map(({ to, label }) => ( ), a: ({ href, children }) => ( - + {children} - + ), + blockquote: ({ children }) => ( + + {children} + + ), + code: ({ children, className }) => { + const value = String(children).replace(/\n$/, '') + + if (className) { + return + } + + return ( + + {children} + + ) + }, ul: ({ children }) => ( {children} @@ -69,7 +101,11 @@ export const markdownComponents: Components = { ), li: ({ children }) => ( - + {children} ), @@ -128,7 +164,10 @@ export const MarkdownPage: React.FC = ({ )} {frontmatter.date && ( - + {new Date(frontmatter.date).toLocaleDateString('en-US', { year: 'numeric', month: 'long', @@ -159,7 +198,28 @@ export const ContentPage: React.FC = ({ src }) => { return res.text() }) .then((text) => { - const parsed = parseFrontmatter(text) + // Replace template placeholders like {{ key }} in the markdown text + // with corresponding values from the `settings` object. + // + // Example: + // "https://{{ ocotillo_api_url }}/ogcapi" + // → "https://actual-value/ogcapi" + // + const hydratedText = text.replace( + /{{\s*([\w]+)\s*}}/g, + (_, key: string) => { + const value = (settings as Record)[key] + + if (typeof value === 'string') { + return value.replace(/\/+$/, '') + } + + // if key not found or not string → reinsert key name + return `{{ ${key} }}` + } + ) + + const parsed = parseFrontmatter(hydratedText) setFrontmatter(parsed.data) setBody(parsed.content) }) @@ -194,3 +254,45 @@ export const ContentPage: React.FC = ({ src }) => { return } + +const CopyCodeBlock = ({ value }: { value: string }) => { + const handleCopy = async () => { + await navigator.clipboard.writeText(value) + } + + return ( + + + {value} + + + + + + + + + ) +} From 0bf4134219f5fa41c55963eeb33d4123fba455e6 Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Tue, 28 Apr 2026 15:58:19 -0400 Subject: [PATCH 22/28] Append local date stamp to map layer CSV download filenames. --- src/pages/ocotillo/map/list.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/pages/ocotillo/map/list.tsx b/src/pages/ocotillo/map/list.tsx index f9c0c587..cea243b9 100644 --- a/src/pages/ocotillo/map/list.tsx +++ b/src/pages/ocotillo/map/list.tsx @@ -53,6 +53,11 @@ import { getSelectedPointColumns, } from '@/utils/mapSelection' +function localDateStampForExport(): string { + const d = new Date() + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` +} + const DEFAULT_VISIBLE_LAYERS = ['ogc-latest-depth-to-water'] const VISIBLE_FEATURES_DRAWER_WIDTH = 360 const VISIBLE_FEATURES_PAGE_SIZE = 10 @@ -519,7 +524,11 @@ export const MapView: React.FC = () => { const url = URL.createObjectURL(blob) const link = document.createElement('a') link.href = url - link.download = `${sanitizeLayerExportFilename(label)}.${suffix}` + const base = sanitizeLayerExportFilename(label) + link.download = + suffix === 'csv' + ? `${base}-${localDateStampForExport()}.csv` + : `${base}.${suffix}` document.body.appendChild(link) link.click() document.body.removeChild(link) From c0fbff151e51c880ec53f23703d8315cfa2a1212 Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Tue, 28 Apr 2026 16:08:06 -0400 Subject: [PATCH 23/28] Fix map export TypeScript: CustomParams for data provider and narrow OGC site_name. --- src/pages/ocotillo/map/list.tsx | 4 ++-- src/utils/wellMapExport.ts | 17 ++++++++++++----- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/pages/ocotillo/map/list.tsx b/src/pages/ocotillo/map/list.tsx index cea243b9..a8969374 100644 --- a/src/pages/ocotillo/map/list.tsx +++ b/src/pages/ocotillo/map/list.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react' import { captureEvent } from '@/analytics/posthog' import { Layer, Source } from 'react-map-gl' import { useDataProvider, useGo } from '@refinedev/core' +import type { CustomParams } from '@refinedev/core' import { useLocation } from 'react-router' import { Box, @@ -572,8 +573,7 @@ export const MapView: React.FC = () => { setExportVisibleBusy(true) try { const ocotillo = dataProvider('ocotillo') - const customRequest = (args: { url: string; method: string }) => - ocotillo.custom(args) + const customRequest = (args: CustomParams) => ocotillo.custom(args) for (let index = 0; index < visiblePointFeaturesByLayer.length; index++) { const { label, features } = visiblePointFeaturesByLayer[index] diff --git a/src/utils/wellMapExport.ts b/src/utils/wellMapExport.ts index 304330e6..0de1010c 100644 --- a/src/utils/wellMapExport.ts +++ b/src/utils/wellMapExport.ts @@ -1,3 +1,4 @@ +import type { CustomParams } from '@refinedev/core' import type { IWellDetails } from '@/interfaces/ocotillo' import { getFeatureId } from '@/utils/mapSelection' import { @@ -9,10 +10,16 @@ import { export { buildWellShowAbsoluteUrl, getOcotilloPublicAppOrigin } from '@/utils/wellPublicUrls' -type CustomGetter = (args: { - url: string - method: string -}) => Promise<{ data: unknown }> +type CustomGetter = (params: CustomParams) => Promise<{ data: unknown }> + +function ogcSiteNameFromProperties( + cleaned: Record +): string | number | undefined { + const v = cleaned.site_name + if (v === null || v === undefined) return undefined + if (typeof v === 'string' || typeof v === 'number') return v + return undefined +} /** * @deprecated use buildWellMapCsvValues from @/well-export/wellMapCsvExport @@ -63,7 +70,7 @@ export async function enrichMapFeaturesWithWellDetails( const id = getFeatureId(feature) const raw = (feature.properties || {}) as Record const cleaned = stripLegacyDetailPrefixedKeys(raw) - const ogcSiteName = cleaned.site_name + const ogcSiteName = ogcSiteNameFromProperties(cleaned) const { site_name: _dropOgcSite, ...restNoOgcSite } = cleaned const baseProps = dropMapCsvExcludedFeatureKeys(restNoOgcSite) From cfe6b7ac44b0e6952dbd4f1b626f685ea116c5bc Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Tue, 28 Apr 2026 15:50:02 -0500 Subject: [PATCH 24/28] feat(public/content/ogcapi): Recreate the ocotillo/help pg in markdown --- public/content/ogcapi.md | 68 ++++++++++++++++++++++ src/pages/content/index.tsx | 109 ++++++++++++++++++++++++++++++++---- src/routes/ocotillo.tsx | 2 - 3 files changed, 167 insertions(+), 12 deletions(-) create mode 100644 public/content/ogcapi.md diff --git a/public/content/ogcapi.md b/public/content/ogcapi.md new file mode 100644 index 00000000..8ecc64aa --- /dev/null +++ b/public/content/ogcapi.md @@ -0,0 +1,68 @@ +--- +title: Connect the Ocotillo OGC API to desktop GIS +deck: Use the Ocotillo OGC API Features endpoint to browse collections in ArcGIS Desktop and in QGIS. +--- + +> [!WARNING] +> OGC API layers are read-only in desktop GIS. Use them for discovery, map display, querying, and export. + +##### Ocotillo OGC landing page URL + +```text +{{ ocotillo_api_url }}/ogcapi +``` + +## ArcGIS Pro / Desktop + +1. Open the **Catalog pane** and create a new OGC API Server connection. +2. Paste the Ocotillo landing page URL. +3. Expand the server connection, choose the collection you want, and add it to the current map. +4. If ArcGIS prompts for layer options, use extent or maximum-feature limits for large collections. + +**Official documentation:** +[https://pro.arcgis.com/en/pro-app/latest/help/data/services/use-ogc-api-services.htm](https://pro.arcgis.com/en/pro-app/latest/help/data/services/use-ogc-api-services.htm) + +--- + +## QGIS + +1. Open **Data Source Manager**. +2. Choose the WFS / OGC API - Features connection tab. +3. Create a new connection using the Ocotillo landing page URL. +4. Connect to the server, select one or more collections, and add them to the map. +5. For large layers, set paging or feature limits in the connection and layer options. + +> [!INFO] +> QGIS expects the OGC API landing page, not a single collection items URL, when you create the server connection. + +**Official documentation:** +[https://docs.qgis.org/latest/en/docs/user_manual/working_with_ogc/ogc_client_support.html](https://docs.qgis.org/latest/en/docs/user_manual/working_with_ogc/ogc_client_support.html) + +--- + +## Useful Ocotillo endpoints + +### Landing page + +[{{ ocotillo_api_url }}/ogcapi]({{ ocotillo_api_url }}/ogcapi) + +Use this as the server URL when creating the connection. + +### Collections + +[{{ ocotillo_api_url }}/ogcapi/collections]({{ ocotillo_api_url }}/ogcapi/collections) + +Review available collections before connecting from desktop GIS. + +--- + +## Common collections to look for + +- [!CHIPS] +- Water Wells +- Springs +- Latest Depth to Water +- Average TDS +- Latest TDS + +Collection names can change by deployment. If you do not see one of these, open the [collections endpoint]({{ ocotillo_api_url }}/ogcapi/collections) and use the names published there. diff --git a/src/pages/content/index.tsx b/src/pages/content/index.tsx index 7ef8e30c..91c99300 100644 --- a/src/pages/content/index.tsx +++ b/src/pages/content/index.tsx @@ -9,6 +9,8 @@ import { Typography, IconButton, Tooltip, + Stack, + Chip, } from '@mui/material' import { ContentCopy } from '@mui/icons-material' import { Components } from 'react-markdown' @@ -72,11 +74,56 @@ export const markdownComponents: Components = { {children} ), - blockquote: ({ children }) => ( - - {children} - - ), + blockquote: ({ children }) => { + const text = React.Children.toArray(children) + .map((child) => { + if (React.isValidElement(child)) { + return React.Children.toArray(child.props.children).join('') + } + + return String(child) + }) + .join('') + .trim() + + const alertMatch = text.match( + /^\[!(WARNING|INFO|ERROR|SUCCESS)\]\s*([\s\S]*)$/i + ) + + if (alertMatch) { + const severityMap = { + WARNING: 'warning', + INFO: 'info', + ERROR: 'error', + SUCCESS: 'success', + } as const + + const alertType = alertMatch[1].toUpperCase() as keyof typeof severityMap + const alertBody = alertMatch[2].trim() + + return ( + + {alertBody} + + ) + } + + return ( + + {children} + + ) + }, code: ({ children, className }) => { const value = String(children).replace(/\n$/, '') @@ -90,11 +137,53 @@ export const markdownComponents: Components = { ) }, - ul: ({ children }) => ( - - {children} - - ), + ul: ({ children, node }) => { + const getListItemText = (listItem: any): string => { + return ( + listItem?.children + ?.map((child: any) => child.value ?? '') + ?.join('') + ?.trim() ?? '' + ) + } + + const listItems = + node?.children?.filter( + (child: any) => child.type === 'element' && child.tagName === 'li' + ) ?? [] + const firstItemText = getListItemText(listItems[0]) + + if (firstItemText === '[!CHIPS]') { + return ( + + {listItems.slice(1).map((item: any, index: number) => { + const label = getListItemText(item) + + return ( + + ) + })} + + ) + } + + return ( + + {children} + + ) + }, ol: ({ children }) => ( {children} diff --git a/src/routes/ocotillo.tsx b/src/routes/ocotillo.tsx index f21cd232..378309df 100644 --- a/src/routes/ocotillo.tsx +++ b/src/routes/ocotillo.tsx @@ -18,7 +18,6 @@ import { SpringShow, } from '@/pages/ocotillo/thing' import { MapView } from '@/pages/ocotillo/map' -import { HelpPage } from '@/pages/ocotillo/help' import { CollectionsPage } from '@/pages/ocotillo/collections' import { LocationList, @@ -132,7 +131,6 @@ export const OcotilloRoutes = () => { } /> } /> - } /> } /> } /> From 87c2cd46bac2f5ec0e7b9d79dcb39c1eecef95ee Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Tue, 28 Apr 2026 15:51:51 -0500 Subject: [PATCH 25/28] chore(ocotillo/help): Rm old help page --- src/pages/ocotillo/help/index.tsx | 1 - src/pages/ocotillo/help/list.tsx | 253 ------------------------------ 2 files changed, 254 deletions(-) delete mode 100644 src/pages/ocotillo/help/index.tsx delete mode 100644 src/pages/ocotillo/help/list.tsx diff --git a/src/pages/ocotillo/help/index.tsx b/src/pages/ocotillo/help/index.tsx deleted file mode 100644 index c1edf1e2..00000000 --- a/src/pages/ocotillo/help/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export * from './list' diff --git a/src/pages/ocotillo/help/list.tsx b/src/pages/ocotillo/help/list.tsx deleted file mode 100644 index 32b62adb..00000000 --- a/src/pages/ocotillo/help/list.tsx +++ /dev/null @@ -1,253 +0,0 @@ -import { - Alert, - Box, - Card, - CardContent, - Chip, - Container, - Divider, - IconButton, - Link, - Stack, - Tooltip, - Typography, -} from '@mui/material' -import { ContentCopy } from '@mui/icons-material' -import Grid from '@mui/material/Grid2' -import { settings } from '@/settings' - -const trimTrailingSlash = (value: string) => value.replace(/\/+$/, '') - -const baseApiUrl = trimTrailingSlash(settings.ocotillo_api_url) -const ogcLandingPageUrl = `${baseApiUrl}/ogcapi` -const ogcCollectionsUrl = `${ogcLandingPageUrl}/collections` - -const commonCollections = [ - 'Water Wells', - 'Springs', - 'Latest Depth to Water', - 'Average TDS', - 'Latest TDS', -] - -const docs = { - arcgis: - 'https://pro.arcgis.com/en/pro-app/latest/help/data/services/use-ogc-api-services.htm', - qgis: 'https://docs.qgis.org/latest/en/docs/user_manual/working_with_ogc/ogc_client_support.html', -} - -export const HelpPage = () => { - const handleCopy = async (value: string) => { - try { - await navigator.clipboard.writeText(value) - } catch (error) { - console.error('Failed to copy OGC URL', error) - } - } - - return ( - - - - - Connect the Ocotillo OGC API to desktop GIS - - - Use the Ocotillo OGC API Features endpoint to browse collections in - ArcGIS Desktop and in QGIS. - - - - - OGC API layers are read-only in desktop GIS. Use them for discovery, - map display, querying, and export. - - - - - Ocotillo OGC landing page URL - - - - - - - - - - - - - - - - - Useful Ocotillo endpoints - - - - - - - - - - - Common collections to look for - - - {commonCollections.map((collection) => ( - - ))} - - - Collection names can change by deployment. If you do not see one - of these, open the{' '} - - collections endpoint - {' '} - and use the names published there. - - - - - - - ) -} - -const InstructionCard = ({ - title, - steps, - note, - href, -}: { - title: string - steps: string[] - note?: string - href: string -}) => ( - - - - {title} - - {steps.map((step) => ( - - {step} - - ))} - - - {note ? ( - - {note} - - ) : null} - - Official documentation - - {href} - - - - - -) - -const EndpointRow = ({ - label, - href, - description, -}: { - label: string - href: string - description: string -}) => ( - - {label} - - {href} - - - {description} - - -) - -const CopyUrlBox = ({ - value, - onCopy, -}: { - value: string - onCopy: (value: string) => void -}) => ( - - - {value} - - - onCopy(value)} - sx={{ - position: 'absolute', - top: 6, - right: 6, - }} - aria-label="Copy OGC landing page URL" - > - - - - -) From 5893f56bd29b44a8ea5fd626a4980983d7d9bdad Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Tue, 28 Apr 2026 15:56:53 -0500 Subject: [PATCH 26/28] chore(layout/sider): Update sider for update pg links --- src/components/layout/sider.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/components/layout/sider.tsx b/src/components/layout/sider.tsx index 85160060..47eb7295 100644 --- a/src/components/layout/sider.tsx +++ b/src/components/layout/sider.tsx @@ -416,9 +416,8 @@ export const ThemedSiderV2: React.FC = ({ > {[ { to: '/about', label: 'About' }, - { to: '/ocotillo/help', label: 'Connect Desktop GIS' }, + { to: '/ogcapi', label: 'Connect Desktop GIS' }, { to: '/report-a-bug', label: 'Report a Bug' }, - { to: '/ogcapi', label: 'Connect Desktop GIS (md)' }, ].map(({ to, label }) => ( Date: Tue, 28 Apr 2026 16:13:29 -0500 Subject: [PATCH 27/28] doc(ogcapi): Update content based on https://nmbgmr.atlassian.net/browse/BDMS-769 ticket --- public/content/ogcapi.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/public/content/ogcapi.md b/public/content/ogcapi.md index 8ecc64aa..5d14294b 100644 --- a/public/content/ogcapi.md +++ b/public/content/ogcapi.md @@ -14,12 +14,14 @@ deck: Use the Ocotillo OGC API Features endpoint to browse collections in ArcGIS ## ArcGIS Pro / Desktop -1. Open the **Catalog pane** and create a new OGC API Server connection. -2. Paste the Ocotillo landing page URL. -3. Expand the server connection, choose the collection you want, and add it to the current map. -4. If ArcGIS prompts for layer options, use extent or maximum-feature limits for large collections. +1. On the **Insert** tab, in the **Project** group, click **Connections > Server > New OGC API Server**. The **Add OGC API Server Connection** dialog box appears. +2. Enter this URL ([{{ ocotillo_api_url }}/ogcapi]({{ ocotillo_api_url }}/ogcapi)) in the **Server URL** text box. +3. Leave the rest of the options as-is and click OK. +4. In the Catalog pane, expand the “Servers” folder. You should see the Ocotillo OGC API connection. Expand the connection, then expand “Features”. Drag the datasets you want into your map area. +5. When adding a layer, a dialog box will appear with spatial extent options. Click OK to add the entire contents. You can also check the “Use Spatial Extent” box and spatially filter via options in the “Get extent from:” box - e.g. spatially filter by existing layers, selected polygon extents, visible extent, etc. **Official documentation:** +[How to add OGC API datasets to ArcGIS Pro](https://pro.arcgis.com/en/pro-app/latest/help/data/services/add-ogc-api-services.htm) [https://pro.arcgis.com/en/pro-app/latest/help/data/services/use-ogc-api-services.htm](https://pro.arcgis.com/en/pro-app/latest/help/data/services/use-ogc-api-services.htm) --- From a1491bb864005305f86877e17877a801892e62ad Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Tue, 28 Apr 2026 17:44:43 -0400 Subject: [PATCH 28/28] Break authentik-provider utils barrel cycle that hung Vitest access-control tests. Import PKCE and JWT helpers from Auth, Http, and Jwt modules instead of the utils index so ApiFetch no longer pulls authentik-provider during module init. Partial-mock @/config with importOriginal instead of vi.importActual inside the mock factory. --- src/providers/authentik-provider.ts | 9 ++++----- .../authentik-provider.access-control.test.ts | 10 ++++------ 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/src/providers/authentik-provider.ts b/src/providers/authentik-provider.ts index 15a93b13..3a81e412 100644 --- a/src/providers/authentik-provider.ts +++ b/src/providers/authentik-provider.ts @@ -10,10 +10,9 @@ import { generateCodeChallenge, generateCodeVerifier, generateOAuthState, - getStatusCode, - hasError, - isJwtExpired, -} from '@/utils' +} from '@/utils/Auth' +import { getStatusCode, hasError } from '@/utils/Http' +import { isJwtExpired } from '@/utils/Jwt' import { HttpStatus } from '@/enums' import { AUTHENTIK_URL, @@ -22,7 +21,7 @@ import { STORAGE_KEYS, IS_TESTING_AUTH, } from '@/config' -import { normalizeAccessControlGroups } from '@/utils' +import { normalizeAccessControlGroups } from '@/utils/accessControl' const gravatarUrl = (email: string) => { let hash = email.trim().toLowerCase() diff --git a/src/test/providers/authentik-provider.access-control.test.ts b/src/test/providers/authentik-provider.access-control.test.ts index d406b1e4..6c5eab8a 100644 --- a/src/test/providers/authentik-provider.access-control.test.ts +++ b/src/test/providers/authentik-provider.access-control.test.ts @@ -50,9 +50,8 @@ describe('authentik provider access-control normalization', () => { }) it('normalizes token groups from the ID token when testing auth is disabled', async () => { - vi.doMock('@/config', async () => { - const actual = - await vi.importActual('@/config') + vi.doMock('@/config', async (importOriginal) => { + const actual = await importOriginal() return { ...actual, IS_TESTING_AUTH: false } }) vi.doMock('jwt-decode', () => ({ @@ -79,9 +78,8 @@ describe('authentik provider access-control normalization', () => { }) it('returns null when there is no ID token', async () => { - vi.doMock('@/config', async () => { - const actual = - await vi.importActual('@/config') + vi.doMock('@/config', async (importOriginal) => { + const actual = await importOriginal() return { ...actual, IS_TESTING_AUTH: false } }) vi.doMock('jwt-decode', () => ({