diff --git a/AGENTS.md b/AGENTS.md index 0b69e684..e166f612 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -120,8 +120,8 @@ 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 = - import.meta.env.DEV || import.meta.env.VITE_APP_ENV === 'preview' +export const BYPASS_GEOTHERMAL_GATE = + import.meta.env.DEV === true || 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. diff --git a/docs/geothermal-well-inventory.md b/docs/geothermal-well-inventory.md index 85e4bf59..d65de9de 100644 --- a/docs/geothermal-well-inventory.md +++ b/docs/geothermal-well-inventory.md @@ -145,8 +145,8 @@ 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. --- @@ -163,7 +163,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 +175,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. ✔ - **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..092dc394 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 } from 'react-router' @@ -50,6 +58,7 @@ import { Check, ChevronDown, ChevronRight, + Flame, Lock, LogOut, Menu, @@ -62,8 +71,14 @@ 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 { useAccessCapabilities } from '@/hooks' +import { + AMP_NAV_ID, + AmpRole, + PRIMARY_NAV, + RESOURCE_NAV, + type NavItem, +} from '@/config/navigation' +import { useAccessCapabilities, useNavSectionOpen } from '@/hooks' import { useSearch } from '@/providers/search-provider' import { SupportPanelContext } from '@/components/SupportPanelContext' import { NewVersionBanner } from '@/components/NewVersionBanner' @@ -154,7 +169,6 @@ function ExpandButton() { ) } - const FOOTER_LINKS = [ { label: 'About', href: '/about' }, { label: 'Connect Desktop GIS', href: '/ogcapi' }, @@ -196,10 +210,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) - const [open, setOpen] = useState(sectionActive) - const isOpen = sectionActive || open - - useEffect(() => { - setOpen(sectionActive) - }, [sectionActive]) + hasChildren && + sectionHrefs.some( + (candidate) => + candidate != null && isNavSectionActive(pathname, candidate) + ) + const [isOpen, setOpen] = useNavSectionOpen(sectionActive) if (!canSeeNavItem(roles)) return null - const handleOpenChange = (next: boolean) => { - if (!sectionActive) setOpen(next) - } + const handleOpenChange = setOpen + + // 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 @@ -254,92 +276,87 @@ function ResourceNavItem({ } if (!hasChildren) { - return ( - - - + + + trackNavClick({ label, href, icon: Icon, resource, roles }) + } > - trackNavClick({ label, href, icon: Icon, resource, roles })} - > - - {label} - {roles && !roles.includes(AmpRole.Viewer) && ( - - )} - - - - + + {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 ( - - - + + {/* Header toggles the group only. Switching page is the child's job, + so opening a group never moves you off the page you are on. */} + + + + {label} + + + + + + {visibleChildren.map((child) => { + const ChildIcon = child.icon + return ( + + + + trackNavClick(child, label)} > - trackNavClick(child, label)} - > - - {child.label} - - - - - ) - })} - - - - - + + {child.label} + + + + + ) + })} + + + + ) } @@ -377,64 +394,91 @@ 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 + ) + } + )} - {/* Resource navigation */} + {/* Resource navigation + WIP geothermal section — all in one group */} {RESOURCE_NAV.map((item) => ( - + + + {/* The geothermal group sits directly below AMP. */} + {item.id === AMP_NAV_ID ? : null} + {/* Domain groups above, flat resources below. Wrapped in an + li because SidebarMenu is a ul. */} + {item.id === AMP_NAV_ID ? ( +
  • + +
  • + ) : null} +
    ))}
    @@ -466,6 +510,69 @@ 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 [open, setOpen] = useNavSectionOpen(isGeothermalPath(location.pathname)) + + return ( + + + + + + Geothermal + + + + + + + + Records + + + + + Inventory + + + + + Temp-Depth + + + + + + + ) +} + function SupportPanelTrigger({ collapsed }: { collapsed: boolean }) { const { isOpen, open, close } = useContext(SupportPanelContext) return ( @@ -476,7 +583,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' )} > @@ -561,7 +670,10 @@ function SupportPanel() { reset() } - const [bugForm, setBugForm] = useState({ whatHappened: '', severity: 'Low' }) + const [bugForm, setBugForm] = useState({ + whatHappened: '', + severity: 'Low', + }) const [featureForm, setFeatureForm] = useState({ problem: '', whoWouldUse: '', @@ -585,20 +697,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` } @@ -674,285 +792,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 */} -
    -
    + +
    + +