diff --git a/package-lock.json b/package-lock.json index b6cf175f..9787f307 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "ocotillo-ui", - "version": "1.0.1", + "version": "1.1.2", "lockfileVersion": 3, "requires": true, "packages": { @@ -31117,4 +31117,4 @@ } } } -} +} \ No newline at end of file diff --git a/package.json b/package.json index c992f5b5..ec5dccf5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ocotillo-ui", - "version": "1.1.1", + "version": "1.1.2", "private": true, "type": "module", "scripts": { diff --git a/public/content/analytics-disclosure.md b/public/content/analytics-disclosure.md new file mode 100644 index 00000000..396a837a --- /dev/null +++ b/public/content/analytics-disclosure.md @@ -0,0 +1,28 @@ +--- +title: Analytics Disclosure +deck: Why Ocotillo records production sessions and uses site analytics. +date: 2026-08-14 +--- + +## What is recorded? + +Ocotillo uses PostHog to collect site analytics and, on the production site, session recordings for signed-in users. A session recording captures page views, clicks, navigation, and general interface behavior so the development team can understand where the application is confusing, slow, or broken. + +Form inputs are masked by default in session recordings. Ocotillo does not use session recordings to collect passwords or intentionally capture sensitive field values. + +## Why we need it + +Ocotillo supports Bureau staff working with geologic data across many workflows. Session analytics help the team: + +- Find broken or confusing screens +- Understand which workflows need performance or usability improvements +- Troubleshoot support requests with the page and browser context needed to reproduce problems +- Prioritize fixes based on real usage instead of guesses + +## When it starts + +Session recording starts after sign-in on the production Ocotillo site. Analytics events may also record basic application activity, such as page views and feature usage. + +## Questions + +Contact the Data Services team at [ocotillo-nmbg@nmt.edu](mailto:ocotillo-nmbg@nmt.edu) with questions about Ocotillo analytics or session recording. diff --git a/src/App.tsx b/src/App.tsx index 07496244..47449304 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,21 +1,36 @@ import { Authenticated } from '@refinedev/core' -import { AuthPage, ErrorComponent } from '@refinedev/mui' +import { ErrorComponent } from '@refinedev/mui' import { BrowserRouter, Navigate, Outlet, Route, Routes } from 'react-router' +import { AppProviders } from '@/AppProviders' import { AppShell } from '@/components/AppShell' -import { ThemedTitleV2 } from '@/components/layout/title' -import { Callback } from '@/components/Auth' +import { Callback, Login } from '@/components/Auth' import { Home } from '@/pages/home' import { TypographyPage } from '@/pages/example/TypographyPage' import { DataGridPage } from '@/pages/example/DataGridPage' import { ContentPage } from '@/pages/content' import { OcotilloRoutes, ST2Routes } from '@/routes' import { settings } from '@/settings' -import { AppProviders } from '@/AppProviders' const App: React.FC = () => ( + } + > + + + } + > + } + /> + }> @@ -24,23 +39,7 @@ const App: React.FC = () => ( } > } /> - } - hideForm={true} - type="login" - registerLink={false} - providers={[ - { - name: 'authentik', - label: 'Sign in with Authentik', - }, - ]} - /> - } - /> + } /> ( + } + hideForm={true} + type="login" + registerLink={false} + providers={[ + { + name: 'authentik', + label: 'Sign in with Authentik', + }, + ]} + renderContent={(content, title) => ( + <> + {title} + {content} + + + Ocotillo uses PostHog analytics and records production sessions to + improve reliability and support users.{' '} + + Learn why. + + + + + )} + /> +) + export const Callback = () => { const queryClient = useQueryClient() const navigate = useNavigate() diff --git a/src/components/ListPage.tsx b/src/components/ListPage.tsx index 3e7e3633..ab5e16f9 100644 --- a/src/components/ListPage.tsx +++ b/src/components/ListPage.tsx @@ -15,6 +15,7 @@ import { useGridSelector, GridColDef, GridRowParams, + MuiEvent, } from '@mui/x-data-grid' import { settings } from '@/settings' import React, { useMemo, useState } from 'react' @@ -22,6 +23,7 @@ import { useNavigate } from 'react-router' import { CanAccess, useExport, + useGetToPath, useNavigation, useResourceParams, } from '@refinedev/core' @@ -154,6 +156,26 @@ function ListPageToolbar({ ) } +/** + * DataGrid rows are not anchors, so modifier clicks would otherwise navigate in + * place. Treat the browser conventions for "open elsewhere" as new-window intent. + */ +export function isNewWindowClick(event: { + ctrlKey?: boolean + metaKey?: boolean + button?: number +}): boolean { + // Shift is left alone: the DataGrid uses it for row range selection. + return Boolean(event.ctrlKey || event.metaKey || event.button === 1) +} + +export function openInNewWindow(href: string) { + // Router paths are basename-relative; window.open is not. + const target = href.startsWith('/') ? `${settings.urlprefix}${href}` : href + const opened = window.open(target, '_blank', 'noopener,noreferrer') + if (opened) opened.opener = null +} + type ListPageProps = { title?: string description?: string @@ -284,21 +306,46 @@ export const ListPage: React.FC = ({ hideExport: restDataGridProps.paginationMode === 'server', } - const handleRowClick = getRowHref - ? (params: GridRowParams) => { - onRowClick?.(params) - navigate(getRowHref(params)) - } - : disableRowClick - ? onRowClick - ? (params: GridRowParams) => onRowClick(params) - : undefined - : resource - ? (params: GridRowParams) => { - onRowClick?.(params) + const getToPath = useGetToPath() + + // Href for the row's destination, used for modifier-click new-window opens. + const resolveRowHref = (params: GridRowParams): string | undefined => { + if (getRowHref) return getRowHref(params) + if (disableRowClick || !resource) return undefined + return getToPath({ + resource, + action: 'show', + meta: { id: params.id }, + }) + } + + const rowClickNavigates = Boolean( + getRowHref || (!disableRowClick && resource) + ) + + const handleRowClick = + rowClickNavigates || onRowClick + ? (params: GridRowParams, event: MuiEvent) => { + onRowClick?.(params) + + if (!rowClickNavigates) return + + const href = resolveRowHref(params) + if (href && isNewWindowClick(event)) { + openInNewWindow(href) + return + } + + if (getRowHref) { + navigate(getRowHref(params)) + return + } + + if (resource) { show(resource.name, params.id as string | number) } - : undefined + } + : undefined const rowCursor = getRowHref || (!disableRowClick && resource) ? 'pointer' : 'default' diff --git a/src/components/layout/sider.tsx b/src/components/layout/sider.tsx index ebe79b21..ce90bc38 100644 --- a/src/components/layout/sider.tsx +++ b/src/components/layout/sider.tsx @@ -417,6 +417,7 @@ export const ThemedSiderV2: React.FC = ({ {[ { to: '/about', label: 'About' }, { to: '/ogcapi', label: 'Connect Desktop GIS' }, + { to: '/analytics-disclosure', label: 'Analytics Disclosure' }, { to: '/report-a-bug', label: 'Report a Bug' }, ].map(({ to, label }) => ( diff --git a/src/pages/ocotillo/thing/list.tsx b/src/pages/ocotillo/thing/list.tsx index 4383847f..da0f4614 100644 --- a/src/pages/ocotillo/thing/list.tsx +++ b/src/pages/ocotillo/thing/list.tsx @@ -1,12 +1,20 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { Link as RouterLink, useNavigate, useSearchParams } from 'react-router' -import { useExport, useGo, useLink, useOne, type CrudFilter } from '@refinedev/core' +import { + useExport, + useGo, + useLink, + useOne, + type CrudFilter, +} from '@refinedev/core' import { useDataGrid } from '@refinedev/mui' +import { GridColDef } from '@mui/x-data-grid' import { - GridColDef, -} from '@mui/x-data-grid' -import { captureEvent, consumeWellsProjectFilterSource, setWellsProjectFilterSource } from '@/analytics/posthog' -import { Download, FileText, Loader2, X } from 'lucide-react' + captureEvent, + consumeWellsProjectFilterSource, + setWellsProjectFilterSource, +} from '@/analytics/posthog' +import { Download, ExternalLink, FileText, Loader2, X } from 'lucide-react' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { ListPage } from '@/components/ListPage' @@ -15,6 +23,7 @@ import { ISpring, IWell } from '@/interfaces/ocotillo' import { IGroup } from '@/interfaces/ocotillo/IGroup' import { displayWellSiteName, formatAppDate, formatAppDateTime } from '@/utils' import { getContactDisplayName } from '@/utils/contactDisplayName' +import { buildWellShowPath } from '@/utils/wellPublicUrls' import { WellListColumnLabels } from '@/well-list/wellListColumnLabels' export const SpringList: React.FC = () => { @@ -84,9 +93,7 @@ export const WellList: React.FC = () => { const projectFilters = useMemo( () => - projectId - ? [{ field: 'groups', operator: 'eq', value: projectId }] - : [], + projectId ? [{ field: 'groups', operator: 'eq', value: projectId }] : [], [projectId] ) @@ -179,6 +186,42 @@ export const WellList: React.FC = () => { const columns = useMemo[]>( () => [ + { + field: 'open_in_new_window', + headerName: '', + description: + 'Open this well detail page in a new browser window so several wells can stay open at once.', + width: 52, + sortable: false, + filterable: false, + hideable: false, + disableColumnMenu: true, + disableExport: true, + align: 'center', + headerAlign: 'center', + // Drop the default cell padding so the link can cover the full cell. + cellClassName: 'relative !p-0', + // The link fills the whole cell so the entire column is the hit target, + // not just the icon glyph. + renderCell: (params) => ( + ) => { + e.stopPropagation() + captureEvent('wells_opened_new_window', { + well_id: params.row.id, + }) + }} + className="absolute inset-0 flex items-center justify-center text-muted-foreground hover:bg-primary/5 hover:text-primary" + > + + + ), + }, { field: 'name', headerName: WellListColumnLabels.name, @@ -480,11 +523,12 @@ export const WellList: React.FC = () => { > {projectId ? (
- -
+ +
Project: {projectName ?? projectId}