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= 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/public/content/ogcapi.md b/public/content/ogcapi.md new file mode 100644 index 00000000..5d14294b --- /dev/null +++ b/public/content/ogcapi.md @@ -0,0 +1,70 @@ +--- +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. 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) + +--- + +## 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/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/ListPage.tsx b/src/components/ListPage.tsx index 912db162..e2436d59 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. */} - + 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/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) => ( ( ) -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/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' +}) => ( + +) diff --git a/src/components/WellShow/WellPhysicalProperties.tsx b/src/components/WellShow/WellPhysicalProperties.tsx deleted file mode 100644 index fa7d1e62..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 WellPhysicalPropertiesAccordion = ({ 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/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/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' diff --git a/src/components/layout/sider.tsx b/src/components/layout/sider.tsx index 039669d2..47eb7295 100644 --- a/src/components/layout/sider.tsx +++ b/src/components/layout/sider.tsx @@ -416,7 +416,7 @@ 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' }, ].map(({ to, label }) => ( 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/content/index.tsx b/src/pages/content/index.tsx index ff296266..91c99300 100644 --- a/src/pages/content/index.tsx +++ b/src/pages/content/index.tsx @@ -1,7 +1,20 @@ import React, { useEffect, useState } from 'react' import ReactMarkdown from 'react-markdown' -import { Box, CircularProgress, Divider, Typography } from '@mui/material' +import { + Alert, + Box, + CircularProgress, + Divider, + Link, + Typography, + IconButton, + Tooltip, + Stack, + Chip, +} from '@mui/material' +import { ContentCopy } from '@mui/icons-material' import { Components } from 'react-markdown' +import { settings } from '@/settings' export type FrontMatter = { title?: string @@ -28,7 +41,10 @@ export function parseFrontmatter(text: string): { const colonIdx = line.indexOf(':') if (colonIdx === -1) continue const key = line.slice(0, colonIdx).trim() - const value = line.slice(colonIdx + 1).trim().replace(/^["']|["']$/g, '') + const value = line + .slice(colonIdx + 1) + .trim() + .replace(/^["']|["']$/g, '') if (key === 'title' || key === 'deck' || key === 'date') { data[key] = value } @@ -54,22 +70,131 @@ export const markdownComponents: Components = { ), a: ({ href, children }) => ( - + {children} - - ), - ul: ({ 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$/, '') + + if (className) { + return + } + + return ( + + {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} ), li: ({ children }) => ( - + {children} ), @@ -128,7 +253,10 @@ export const MarkdownPage: React.FC = ({ )} {frontmatter.date && ( - + {new Date(frontmatter.date).toLocaleDateString('en-US', { year: 'numeric', month: 'long', @@ -159,7 +287,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 +343,45 @@ export const ContentPage: React.FC = ({ src }) => { return } + +const CopyCodeBlock = ({ value }: { value: string }) => { + const handleCopy = async () => { + await navigator.clipboard.writeText(value) + } + + return ( + + + {value} + + + + + + + + + ) +} 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/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" - > - - - - -) diff --git a/src/pages/ocotillo/map/list.tsx b/src/pages/ocotillo/map/list.tsx index 7a58f288..a8969374 100644 --- a/src/pages/ocotillo/map/list.tsx +++ b/src/pages/ocotillo/map/list.tsx @@ -1,7 +1,8 @@ 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 type { CustomParams } from '@refinedev/core' import { useLocation } from 'react-router' import { Box, @@ -44,6 +45,8 @@ import { filterLayerFeaturesBySelection, sanitizeLayerExportFilename, } from '@/utils/layerExport' +import { enrichMapFeaturesWithWellDetails } from '@/utils/wellMapExport' +import { buildMapExportPreferredColumnOrder } from '@/well-export/wellMapCsvExport' import { getSelectedPointColumnLabel, getFeatureId, @@ -51,6 +54,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 @@ -227,6 +235,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) @@ -514,7 +525,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) @@ -544,7 +559,9 @@ export const MapView: React.FC = () => { } downloadLayerBlob( - buildLayerCsv(features), + buildLayerCsv(features, { + preferredPropertyColumnOrder: buildMapExportPreferredColumnOrder(), + }), 'text/csv;charset=utf-8;', 'csv', label, @@ -552,18 +569,31 @@ 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: CustomParams) => 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 +1566,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}`} diff --git a/src/pages/ocotillo/thing/list.tsx b/src/pages/ocotillo/thing/list.tsx index 093ab667..b29c4f7b 100644 --- a/src/pages/ocotillo/thing/list.tsx +++ b/src/pages/ocotillo/thing/list.tsx @@ -7,7 +7,8 @@ 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' +import { WellListColumnLabels } from '@/well-list/wellListColumnLabels' export const SpringList: React.FC = () => { const { dataGridProps } = useDataGrid({ @@ -115,35 +116,61 @@ 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', minWidth: 100, flex: 1, }, { - field: 'well_status', - headerName: 'Well Status', + field: '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', - width: 150, + minWidth: 140, + flex: 0.9, + valueGetter: (_: unknown, row: IWell) => displayWellSiteName(row), }, { 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', width: 160, }, + { + field: 'created_at', + headerName: WellListColumnLabels.createdAt, + description: + 'Calendar date when this well record was first added to Ocotillo.', + width: 130, + valueGetter: (v: string) => formatAppDate(v), + }, + { + field: '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', width: 130, }, { 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, flex: 1, - sortable: false, valueGetter: (_: unknown, row: IWell) => row.aquifers ?.map( @@ -154,13 +181,17 @@ 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', width: 130, }, { 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', width: 130, align: 'right', @@ -168,7 +199,9 @@ 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', width: 130, align: 'right', @@ -176,16 +209,19 @@ 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, valueGetter: (v: string) => formatAppDate(v), }, { 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, flex: 1, - sortable: false, valueGetter: (_: unknown, row: IWell) => row.contacts?.map((c) => c.name ?? '').join(', ') ?? '', renderCell: (params) => { @@ -221,20 +257,24 @@ 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, flex: 1, }, { field: 'latitude', - headerName: 'Latitude', + headerName: WellListColumnLabels.latitude, + description: + 'Latitude of the current mapped location in decimal degrees (WGS84).', type: 'number', width: 110, sortable: false, @@ -245,7 +285,9 @@ 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', width: 110, sortable: false, @@ -256,7 +298,9 @@ 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, flex: 1, sortable: false, @@ -265,12 +309,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), - }, ], [] ) diff --git a/src/pages/ocotillo/thing/well-show.tsx b/src/pages/ocotillo/thing/well-show.tsx index 50be1c27..7db4699a 100644 --- a/src/pages/ocotillo/thing/well-show.tsx +++ b/src/pages/ocotillo/thing/well-show.tsx @@ -26,23 +26,23 @@ import { HydrographCard, RecentWaterLevelObservationsCard, ContactsCard, - AttachmentsAccordion, - AlternateIdsAccordion, + AttachmentsCard, + AlternateIdsCard, USGSInfoCard, OSEPODInfoCard, WellPDFPreviewButton, - WellScreensAccordion, - EquipmentAccordion, + WellScreensCard, + EquipmentCard, NotesAccordion, - ConstructionInfoAccordion, - GeologyInformationAccordion, - WellPhysicalPropertiesAccordion, + ConstructionInfoCard, + GeologyInformationCard, WellPDFDownloadButton, WellShowTitle, OwnerPermissionsCard, MonitoringInfoCard, WaterLevelObservationRow, } from '@/components' +import { displayWellSiteName } from '@/utils' const EMPTY_ASSETS: IAsset[] = [] const EMPTY_CONTACTS: IContact[] = [] @@ -52,8 +52,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() @@ -80,7 +79,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() @@ -373,17 +387,17 @@ export const WellShow = () => { isLoading={isDetailsLoading} /> - - - - + @@ -398,7 +412,7 @@ export const WellShow = () => { { isLoading={isDetailsLoading} /> - - - + + 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/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 = () => { } /> } /> - } /> } /> } /> 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: () => , 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', () => ({ diff --git a/src/utils/index.ts b/src/utils/index.ts index 58074553..17855784 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -26,6 +26,7 @@ export * from './Unit' export * from './UpdateMapView' export * from './UtmToLonLat' export * from './WellBatchExport' +export * from './wellSiteName' export * from './BuildBugReportUrl' export * from './docsSearch' export * from './searchModal' 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, diff --git a/src/utils/wellMapExport.ts b/src/utils/wellMapExport.ts new file mode 100644 index 00000000..0de1010c --- /dev/null +++ b/src/utils/wellMapExport.ts @@ -0,0 +1,102 @@ +import type { CustomParams } from '@refinedev/core' +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 = (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 + */ +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 = ogcSiteNameFromProperties(cleaned) + 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 } + }) +} 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}` +} diff --git a/src/utils/wellSiteName.ts b/src/utils/wellSiteName.ts new file mode 100644 index 00000000..7279c059 --- /dev/null +++ b/src/utils/wellSiteName.ts @@ -0,0 +1,18 @@ +import type { IWell } from '@/interfaces/ocotillo' + +/** + * Site name shown in lists and exports: uses API `site_name` when set, otherwise + * the same rule as OcotilloAPI `Thing.site_name` (lowest id among NMBGMR links). + */ +export function displayWellSiteName( + well: Pick +): 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 ?? '' +} 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, + } +} 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