diff --git a/src/analytics/posthog.ts b/src/analytics/posthog.ts index 84c67485..9f6f29d5 100644 --- a/src/analytics/posthog.ts +++ b/src/analytics/posthog.ts @@ -45,6 +45,30 @@ export const initPostHog = () => { initialized = true } +export const WELLS_PROJECT_FILTER_SOURCE_KEY = 'wells_project_filter_source' + +export type WellsProjectFilterSource = 'projects_list' | 'wells_column' | 'direct' + +export const setWellsProjectFilterSource = (source: WellsProjectFilterSource) => { + sessionStorage.setItem(WELLS_PROJECT_FILTER_SOURCE_KEY, source) +} + +export const consumeWellsProjectFilterSource = (): WellsProjectFilterSource => { + const value = sessionStorage.getItem(WELLS_PROJECT_FILTER_SOURCE_KEY) + sessionStorage.removeItem(WELLS_PROJECT_FILTER_SOURCE_KEY) + if (value === 'projects_list' || value === 'wells_column') return value + return 'direct' +} + +export const trackNavItemClicked = (props: { + label: string + href: string + resource?: string + parent_label?: string +}) => { + captureEvent('nav_item_clicked', props) +} + /** * Optional properties for well detail pages so `well_id` is on `$pageview` * (and shows up in PostHog when breaking down or filtering). @@ -77,6 +101,44 @@ export const wellDetailPageviewProps = ( return undefined } +/** + * Extra `$pageview` properties for list pages (Projects, filtered Wells, etc.). + */ +export const listPageviewProps = ( + pathname: string, + search: string +): Record | undefined => { + if (pathname === '/ocotillo/well/projects') { + return { page_template: 'projects_list' } + } + + if (pathname === '/ocotillo/well') { + const projectId = new URLSearchParams( + search.startsWith('?') ? search.slice(1) : search + ).get('projectId') + if (projectId) { + return { + page_template: 'wells_list', + wells_view: 'project_filtered', + project_id: projectId, + } + } + return { page_template: 'wells_list' } + } + + if (pathname === '/ocotillo/contact') { + return { page_template: 'contacts_list' } + } + + return undefined +} + +export const pageviewExtras = ( + pathname: string, + search: string +): Record | undefined => + wellDetailPageviewProps(pathname) ?? listPageviewProps(pathname, search) + export const capturePostHogPageview = ( path: string, extras?: Record diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index 674ea3fe..e2d59f0b 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -67,6 +67,7 @@ import { useAccessCapabilities } from '@/hooks' import { useSearch } from '@/providers/search-provider' import { SupportPanelContext } from '@/components/SupportPanelContext' import { NewVersionBanner } from '@/components/NewVersionBanner' +import { trackNavItemClicked } from '@/analytics/posthog' import pkg from '../../package.json' import { Textarea } from '@/components/ui/textarea' import { Label } from '@/components/ui/label' @@ -164,15 +165,26 @@ function isActive(pathname: string, href: string) { return pathname === href || pathname.startsWith(href + '/') } +function collectNavHrefs(items: NavItem[]): string[] { + return items.flatMap((item) => { + const hrefs = item.href ? [item.href] : [] + if (item.children?.length) { + hrefs.push(...collectNavHrefs(item.children)) + } + return hrefs + }) +} + /** * Returns the href of the most specific nav item that matches the current * pathname. Prevents a shallow route (e.g. /ocotillo/well) from staying * active when a deeper route (e.g. /ocotillo/well/batch-export) is open. */ function activeHref(pathname: string): string | null { - const allHrefs = [...PRIMARY_NAV, ...RESOURCE_NAV] - .map((item) => item.href) - .filter(Boolean) as string[] + const allHrefs = [ + ...collectNavHrefs(PRIMARY_NAV), + ...collectNavHrefs(RESOURCE_NAV), + ] const matches = allHrefs.filter((h) => isActive(pathname, h)) if (matches.length === 0) return null return matches.reduce((a, b) => (a.length >= b.length ? a : b)) @@ -197,6 +209,139 @@ function SidebarBrand() { ) } +function isNavSectionActive(pathname: string, href: string) { + return pathname === href || pathname.startsWith(`${href}/`) +} + +function ResourceNavItem({ + item, + pathname, + canSeeNavItem, +}: { + item: NavItem + pathname: string + canSeeNavItem: (itemRoles: NavItem['roles']) => boolean +}) { + const { label, href, icon: Icon, resource, roles, children } = item + const visibleChildren = + children?.filter((child) => canSeeNavItem(child.roles)) ?? [] + const hasChildren = visibleChildren.length > 0 + const currentActiveHref = activeHref(pathname) + const sectionActive = + hasChildren && href != null && isNavSectionActive(pathname, href) + const [open, setOpen] = useState(sectionActive) + const isOpen = sectionActive || open + + useEffect(() => { + setOpen(sectionActive) + }, [sectionActive]) + + if (!canSeeNavItem(roles)) return null + + const handleOpenChange = (next: boolean) => { + if (!sectionActive) setOpen(next) + } + + const trackNavClick = (target: NavItem, parentLabel?: string) => { + if (!target.href) return + trackNavItemClicked({ + label: target.label, + href: target.href, + resource: target.resource, + ...(parentLabel ? { parent_label: parentLabel } : {}), + }) + } + + if (!hasChildren) { + return ( + + + + 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 ( + + + + trackNavClick(child, label)} + > + + {child.label} + + + + + ) + })} + + + + + + ) +} + function AppSidebar() { const location = useLocation() const { state } = useSidebar() @@ -282,32 +427,14 @@ function AppSidebar() { - {RESOURCE_NAV.map(({ label, href, icon: Icon, resource, roles }) => { - if (!canSeeNavItem(roles)) return null - - return ( - - - - - - {label} - {roles && !roles.includes(AmpRole.Viewer) && ( - - )} - - - - - ) - })} + {RESOURCE_NAV.map((item) => ( + + ))} {/* ── TEMPORARY: Example section — hidden until editing-tools is ready ── */} {/* */} @@ -898,12 +1025,42 @@ const BREADCRUMB_RESOURCES: Record< sample: { label: 'Samples', listHref: '/ocotillo/sample', resource: 'sample' }, } +// Nested list pages shown as Parent > Current in the header bar +const NESTED_LIST_BREADCRUMBS: Record< + string, + { parentLabel: string; parentHref: string; label: string } +> = {} + // Pattern: ///show/ or ///edit/ const DETAIL_PATTERN = /\/([a-z0-9-]+)\/(show|edit)\/([^/]+)$/ +function NestedListBreadcrumb({ + parentLabel, + parentHref, + label, +}: { + parentLabel: string + parentHref: string + label: string +}) { + return ( + + ) +} + function HeaderBreadcrumb() { const location = useLocation() + const nestedList = NESTED_LIST_BREADCRUMBS[location.pathname] const routeMatch = location.pathname.match(DETAIL_PATTERN) const slug = routeMatch?.[1] ?? '' const id = routeMatch?.[3] ?? '' @@ -912,18 +1069,22 @@ function HeaderBreadcrumb() { const { query } = useOne({ resource: resourceInfo?.resource ?? '', id, - queryOptions: { enabled: !!id && !!resourceInfo }, + queryOptions: { enabled: !nestedList && !!id && !!resourceInfo }, }) const recordName = (query?.data?.data as Record | undefined)?.name as | string | undefined + if (nestedList) { + return + } + if (!routeMatch || !resourceInfo) return null const recordLabel = recordName ?? `#${id}` return ( -