From 60568a9835dc1770d329fb62ad4a605b449846b6 Mon Sep 17 00:00:00 2001 From: jakeross Date: Thu, 27 Aug 2026 07:59:05 -0700 Subject: [PATCH 1/6] feat(nav): add the Geothermal nav group This is the half of "drop the Sandbox concept" that never landed. The original commit was titled "drop the Sandbox concept for a Geothermal nav group" and did both; an amend before PR #358 kept only the removal, so the geothermal grids lost their Sandbox entry without gaining the replacement. They have been reachable by URL only ever since. - Add GeothermalNavItem: a top-level Geothermal group holding Records, Inventory, and Temp-Depth. - Add SHOW_WIP_NAV, gated on dev or preview only, matching the existing gate in recordsGridLogic.ts. Staging and production do not show it. - Update AGENTS.md and the well-inventory doc, which still described nav placement as pending. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 4 +- docs/geothermal-well-inventory.md | 9 ++-- src/components/AppShell.tsx | 84 +++++++++++++++++++++++++++++-- src/config/navigation.ts | 10 ++++ 4 files changed, 98 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0b69e684..d85513dc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -120,11 +120,11 @@ UI layering is documented in `FRONTEND.md` — read it before adding styles. Sho In code, that means WIP surfaces gate on dev or preview, never on staging: ```ts -export const SHOW_WIP_FEATURES = +export const SHOW_WIP_NAV = import.meta.env.DEV || import.meta.env.VITE_APP_ENV === 'preview' ``` -`recordsGridLogic.ts` gates exactly this way. Do not add `'staging'` to that check, and do not add a staging arm to a new one. +`SHOW_WIP_NAV` in `src/config/navigation.ts` is the flag for WIP nav entries; `recordsGridLogic.ts` gates the same way. Do not add `'staging'` to either check. ### Where to branch from diff --git a/docs/geothermal-well-inventory.md b/docs/geothermal-well-inventory.md index 85e4bf59..fa59ed78 100644 --- a/docs/geothermal-well-inventory.md +++ b/docs/geothermal-well-inventory.md @@ -145,8 +145,9 @@ as the records grid (bypassed in local dev, enforced in prod). ## 8. Navigation & route - Route: `/geothermal/wells/inventory`. -- Nav: none yet. The page is reachable by URL while the work is in progress; - it gets a nav entry when a geothermal nav group lands. +- Nav: an **Inventory** entry under the **Geothermal** nav group, alongside + Records and Temp-Depth. The group is gated by `SHOW_WIP_NAV`, so it appears + in local dev and on PR preview deploys only. --- @@ -163,7 +164,7 @@ as the records grid (bypassed in local dev, enforced in prod). - Boolean, date, and **dropdown** cell kinds in `EditableDataGrid`, plus allowed-value lists for the enum fields. - Inventory page (grid + toolbar: Add rows, Upload CSV, Download template, Save). -- Route (no nav entry yet). +- Route + nav entry (Geothermal group). - Create-only save wrapper (adapt records-grid save to POST-only). --- @@ -175,7 +176,7 @@ as the records grid (bypassed in local dev, enforced in prod). surface the conflict inline. No client-side dedupe/upsert in v1. ✔ - **Enum fields** (`well_type`, `well_class`, `status`): **dropdowns** from fixed allowed-value lists (new select cell kind). ✔ -- **Nav placement:** none yet — URL-only until a geothermal nav group lands. ✔ +- **Nav placement:** Geothermal nav group, preview/dev only. ✔ - **Records vs inventory:** inventory is create-wells only; the existing records grid is untouched. ✔ diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index f3a4b7d6..96fc37b6 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -1,7 +1,7 @@ import { useCallback, useContext, useEffect, useRef, useState } from 'react' import { cn } from '@/lib/utils' import { useIsMobile } from '@/hooks/use-mobile' -import { Outlet, Link, useLocation } from 'react-router' +import { Outlet, Link, useLocation, useNavigate } from 'react-router' import { CanAccess, useCustomMutation, @@ -50,6 +50,7 @@ import { Check, ChevronDown, ChevronRight, + Flame, Lock, LogOut, Menu, @@ -62,7 +63,7 @@ import { import { ColorModeContext } from '@/contexts' import SearchBar from '@/components/SearchBar' import { ReportBugButton } from '@/components/Button' -import { AmpRole, PRIMARY_NAV, RESOURCE_NAV, type NavItem } from '@/config/navigation' +import { AmpRole, PRIMARY_NAV, RESOURCE_NAV, SHOW_WIP_NAV, type NavItem } from '@/config/navigation' import { useAccessCapabilities } from '@/hooks' import { useSearch } from '@/providers/search-provider' import { SupportPanelContext } from '@/components/SupportPanelContext' @@ -424,7 +425,7 @@ function AppSidebar() { - {/* Resource navigation */} + {/* Resource navigation + WIP geothermal section — all in one group */} @@ -436,6 +437,8 @@ function AppSidebar() { canSeeNavItem={canSeeNavItem} /> ))} + {/* WIP geothermal grids — toggle SHOW_WIP_NAV in config/navigation.ts */} + {SHOW_WIP_NAV ? : null} @@ -466,6 +469,81 @@ function AppSidebar() { ) } +const GEOTHERMAL_RECORDS_GRID = '/geothermal/wells/records-grid' +const GEOTHERMAL_INVENTORY = '/geothermal/wells/inventory' +const GEOTHERMAL_TEMP_DEPTH = '/geothermal/wells/temp-depth' + +function isGeothermalPath(pathname: string): boolean { + return ( + pathname.startsWith(GEOTHERMAL_RECORDS_GRID) || + pathname.startsWith(GEOTHERMAL_INVENTORY) || + pathname.startsWith(GEOTHERMAL_TEMP_DEPTH) + ) +} + +function GeothermalNavItem() { + const location = useLocation() + const navigate = useNavigate() + const [open, setOpen] = useState(isGeothermalPath(location.pathname)) + + useEffect(() => { + if (!isGeothermalPath(location.pathname)) setOpen(false) + }, [location.pathname]) + + const handleClick = () => { + setOpen(true) + navigate(GEOTHERMAL_RECORDS_GRID) + } + + return ( + + + + + + Geothermal + + + + + + + + Records + + + + + Inventory + + + + + Temp-Depth + + + + + + + ) +} + function SupportPanelTrigger({ collapsed }: { collapsed: boolean }) { const { isOpen, open, close } = useContext(SupportPanelContext) return ( diff --git a/src/config/navigation.ts b/src/config/navigation.ts index f9a9a124..e07bcd44 100644 --- a/src/config/navigation.ts +++ b/src/config/navigation.ts @@ -55,6 +55,16 @@ export type NavItem = { children?: NavItem[] } +/** + * Show nav entries for work in progress (currently the geothermal grids). + * Visible in local dev and on PR preview deploys (VITE_APP_ENV=preview) — + * a preview branch is where unfinished work gets exercised. Hidden on + * staging and production: staging is a pre-production release branch, not a + * place to try things out. + */ +export const SHOW_WIP_NAV = + import.meta.env.DEV || import.meta.env.VITE_APP_ENV === 'preview' + /** * Top bar: views and tools. * Items without `roles` are visible to every authenticated user. From 507d7e85d532dc97e3ca42521220b050e481cc59 Mon Sep 17 00:00:00 2001 From: jakeross Date: Mon, 31 Aug 2026 12:51:59 -0700 Subject: [PATCH 2/6] feat(nav): group Wells, Field Sheets and Contacts under AMP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sidebar listed nine peers with no sense of which domain each belonged to. Wells, Field Sheets and Contacts are the AMP surfaces, so they move under an AMP group, and the geothermal group follows directly below it. ResourceNavItem needed two changes to support a grouping entry: - A group is now active for its own href *or* any visible child's. Matching only the parent href collapsed the group when moving to a child that does not nest under it — Contacts, at /ocotillo/contact, is not under /ocotillo/well. - A parent may omit `resource`. AMP is a grouping entry, not a resource, and gating it on one of its children's would hide the whole group from anyone who cannot see that child. Each child still gates on its own, the way PRIMARY_NAV already allowed. AppShell anchors the geothermal group to AMP_NAV_ID rather than the label, so rewording "AMP" cannot silently move it. --- src/components/AppShell.tsx | 977 +++++++++++++++++------------ src/config/navigation.ts | 52 +- src/test/config/navigation.test.ts | 80 +++ 3 files changed, 687 insertions(+), 422 deletions(-) create mode 100644 src/test/config/navigation.test.ts diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index 96fc37b6..70f50a10 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -1,4 +1,12 @@ -import { useCallback, useContext, useEffect, useRef, useState } from 'react' +import { + Fragment, + type ReactElement, + useCallback, + useContext, + useEffect, + useRef, + useState, +} from 'react' import { cn } from '@/lib/utils' import { useIsMobile } from '@/hooks/use-mobile' import { Outlet, Link, useLocation, useNavigate } from 'react-router' @@ -63,7 +71,14 @@ import { import { ColorModeContext } from '@/contexts' import SearchBar from '@/components/SearchBar' import { ReportBugButton } from '@/components/Button' -import { AmpRole, PRIMARY_NAV, RESOURCE_NAV, SHOW_WIP_NAV, type NavItem } from '@/config/navigation' +import { + AMP_NAV_ID, + AmpRole, + PRIMARY_NAV, + RESOURCE_NAV, + SHOW_WIP_NAV, + type NavItem, +} from '@/config/navigation' import { useAccessCapabilities } from '@/hooks' import { useSearch } from '@/providers/search-provider' import { SupportPanelContext } from '@/components/SupportPanelContext' @@ -155,7 +170,6 @@ function ExpandButton() { ) } - const FOOTER_LINKS = [ { label: 'About', href: '/about' }, { label: 'Connect Desktop GIS', href: '/ogcapi' }, @@ -197,10 +211,7 @@ function SidebarBrand() { const collapsed = state === 'collapsed' return ( - + canSeeNavItem(child.roles)) ?? [] const hasChildren = visibleChildren.length > 0 const currentActiveHref = activeHref(pathname) + // A group counts as active for its own href and for any of its children's, + // so moving between siblings does not collapse it. + const sectionHrefs = [href, ...visibleChildren.map((child) => child.href)] const sectionActive = - hasChildren && href != null && isNavSectionActive(pathname, href) + hasChildren && + sectionHrefs.some( + (candidate) => + candidate != null && isNavSectionActive(pathname, candidate) + ) const [open, setOpen] = useState(sectionActive) const isOpen = sectionActive || open @@ -244,6 +262,17 @@ function ResourceNavItem({ if (!sectionActive) setOpen(next) } + // Grouping entries need not be resources themselves — each child still gates + // on its own, so an unnamed parent is not an unguarded one. + const withAccess = (node: ReactElement) => + resource ? ( + + {node} + + ) : ( + node + ) + const trackNavClick = (target: NavItem, parentLabel?: string) => { if (!target.href) return trackNavItemClicked({ @@ -255,9 +284,43 @@ function ResourceNavItem({ } if (!hasChildren) { - return ( - - + return withAccess( + + + + trackNavClick({ label, href, icon: Icon, resource, roles }) + } + > + + {label} + {roles && !roles.includes(AmpRole.Viewer) && ( + + )} + + + + ) + } + + const groupClass = `group/nav-${resource?.replace(/\./g, '-') ?? label}` + + return withAccess( + + + trackNavClick({ label, href, icon: Icon, resource, roles })} + onClick={() => + trackNavClick({ label, href, icon: Icon, resource, roles }) + } > {label} - {roles && !roles.includes(AmpRole.Viewer) && ( - - )} + - - - ) - } - - const groupClass = `group/nav-${resource?.replace(/\./g, '-') ?? label}` - - return ( - - - - - - trackNavClick({ label, href, icon: Icon, resource, roles })} - > - - {label} - - - - - - - {visibleChildren.map((child) => { - const ChildIcon = child.icon - return ( - - - + + + {visibleChildren.map((child) => { + const ChildIcon = child.icon + return ( + + + + trackNavClick(child, label)} > - trackNavClick(child, label)} - > - - {child.label} - - - - - ) - })} - - - - - + + {child.label} + + + + + ) + })} + + + + ) } @@ -378,47 +411,64 @@ function AppSidebar() { - {PRIMARY_NAV.map(({ id, label, href, icon: Icon, disabled, resource, roles }) => { - if (!canSeeNavItem(roles)) return null - - const button = id === 'search' ? ( - - - {label} - - ) : disabled ? ( - - - {label} - - ) : ( - - - - {label} - - - ) - - const item = ( - - {button} - - ) - - return resource ? ( - - {item} - - ) : item - })} + {PRIMARY_NAV.map( + ({ + id, + label, + href, + icon: Icon, + disabled, + resource, + roles, + }) => { + if (!canSeeNavItem(roles)) return null + + const button = + id === 'search' ? ( + + + {label} + + ) : disabled ? ( + + + {label} + + ) : ( + + + + {label} + + + ) + + const item = ( + + {button} + + ) + + return resource ? ( + + {item} + + ) : ( + item + ) + } + )} @@ -430,15 +480,19 @@ function AppSidebar() { {RESOURCE_NAV.map((item) => ( - + + + {/* WIP geothermal grids sit directly below AMP — toggle + SHOW_WIP_NAV in config/navigation.ts */} + {SHOW_WIP_NAV && item.id === AMP_NAV_ID ? ( + + ) : null} + ))} - {/* WIP geothermal grids — toggle SHOW_WIP_NAV in config/navigation.ts */} - {SHOW_WIP_NAV ? : null} @@ -514,9 +568,7 @@ function GeothermalNavItem() { Records @@ -554,7 +606,9 @@ function SupportPanelTrigger({ collapsed }: { collapsed: boolean }) { className={cn( 'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors hover:bg-accent cursor-pointer', collapsed && 'justify-center', - isOpen ? 'text-foreground bg-accent' : 'text-muted-foreground hover:text-foreground' + isOpen + ? 'text-foreground bg-accent' + : 'text-muted-foreground hover:text-foreground' )} > @@ -639,7 +693,10 @@ function SupportPanel() { reset() } - const [bugForm, setBugForm] = useState({ whatHappened: '', severity: 'Low' }) + const [bugForm, setBugForm] = useState({ + whatHappened: '', + severity: 'Low', + }) const [featureForm, setFeatureForm] = useState({ problem: '', whoWouldUse: '', @@ -663,20 +720,26 @@ function SupportPanel() { } }, [isOpen]) - const onMouseDown = useCallback((e: React.MouseEvent) => { - e.preventDefault() - dragState.current = { startX: e.clientX, startWidth: width } - if (outerRef.current) outerRef.current.style.transition = 'none' - document.body.style.cursor = 'col-resize' - document.body.style.userSelect = 'none' - }, [width]) + const onMouseDown = useCallback( + (e: React.MouseEvent) => { + e.preventDefault() + dragState.current = { startX: e.clientX, startWidth: width } + if (outerRef.current) outerRef.current.style.transition = 'none' + document.body.style.cursor = 'col-resize' + document.body.style.userSelect = 'none' + }, + [width] + ) useEffect(() => { const onMouseMove = (e: MouseEvent) => { if (!dragState.current) return const maxWidth = Math.floor(window.innerWidth / 2) const delta = dragState.current.startX - e.clientX - const next = Math.min(maxWidth, Math.max(PANEL_MIN_WIDTH, dragState.current.startWidth + delta)) + const next = Math.min( + maxWidth, + Math.max(PANEL_MIN_WIDTH, dragState.current.startWidth + delta) + ) if (outerRef.current) outerRef.current.style.width = `${next}px` if (innerRef.current) innerRef.current.style.width = `${next}px` } @@ -752,285 +815,357 @@ function SupportPanel() { className="flex h-full w-full flex-col" style={isMobile ? undefined : { width }} > - {/* Panel header */} -
-
- {view !== 'home' && ( + {/* Panel header */} +
+
+ {view !== 'home' && ( + + )} + + {view === 'home' && 'Get Help'} + {view === 'bug' && 'Report a Bug'} + {view === 'feature' && 'Request a Feature'} + +
+ +
+ + {/* Panel body */} +
+ {/* Home view */} + {view === 'home' && ( +
+

+ Found something broken or have a suggestion? Let us know. +

+
- )} - - {view === 'home' && 'Get Help'} - {view === 'bug' && 'Report a Bug'} - {view === 'feature' && 'Request a Feature'} - +
+ {/* Brand-blue gradient border: brand-300 → brand-500 → brand-700 */} +
+ +
+

+ For urgent issues, email{' '} + + ocotillo-nmbg@nmt.edu + +

- -
- - {/* Panel body */} -
+ )} - {/* Home view */} - {view === 'home' && ( -
+ {/* Bug form */} + {view === 'bug' && ( +
+ {/* Auto-captured context */} +
+

+ Captured automatically +

- Found something broken or have a suggestion? Let us know. + Page:{' '} + {pageUrl}

-
- -
- {/* Brand-blue gradient border: brand-300 → brand-500 → brand-700 */} -
-
+ +
+ +