Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions src/analytics/posthog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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<string, unknown> | 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<string, unknown> | undefined =>
wellDetailPageviewProps(pathname) ?? listPageviewProps(pathname, search)

export const capturePostHogPageview = (
path: string,
extras?: Record<string, unknown>
Expand Down
225 changes: 193 additions & 32 deletions src/components/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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))
Expand All @@ -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 (
<CanAccess resource={resource!} action="list">
<SidebarMenuItem>
<SidebarMenuButton
asChild
isActive={currentActiveHref === href}
tooltip={label}
>
<Link
to={href!}
onClick={() => trackNavClick({ label, href, icon: Icon, resource, roles })}
>
<Icon />
<span>{label}</span>
{roles && !roles.includes(AmpRole.Viewer) && (
<Lock
className="ml-auto text-muted-foreground/70 shrink-0"
style={{ width: 11, height: 11 }}
/>
)}
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</CanAccess>
)
}

const groupClass = `group/nav-${resource?.replace(/\./g, '-') ?? label}`

return (
<CanAccess resource={resource!} action="list">
<Collapsible open={isOpen} onOpenChange={handleOpenChange} className={groupClass}>
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<SidebarMenuButton
asChild
isActive={currentActiveHref === href}
tooltip={label}
>
<Link
to={href!}
onClick={() => trackNavClick({ label, href, icon: Icon, resource, roles })}
>
<Icon />
<span>{label}</span>
<ChevronRight
className={cn(
'ml-auto size-3.5 transition-transform duration-100',
isOpen && 'rotate-90'
)}
/>
</Link>
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{visibleChildren.map((child) => {
const ChildIcon = child.icon
return (
<CanAccess
key={child.href}
resource={child.resource!}
action="list"
>
<SidebarMenuSubItem>
<SidebarMenuSubButton
asChild
isActive={currentActiveHref === child.href}
>
<Link
to={child.href!}
onClick={() => trackNavClick(child, label)}
>
<ChildIcon />
<span>{child.label}</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
</CanAccess>
)
})}
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
</CanAccess>
)
}

function AppSidebar() {
const location = useLocation()
const { state } = useSidebar()
Expand Down Expand Up @@ -282,32 +427,14 @@ function AppSidebar() {
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
{RESOURCE_NAV.map(({ label, href, icon: Icon, resource, roles }) => {
if (!canSeeNavItem(roles)) return null

return (
<CanAccess key={`resource-${href}`} resource={resource!} action="list">
<SidebarMenuItem>
<SidebarMenuButton
asChild
isActive={activeHref(location.pathname) === href}
tooltip={label}
>
<Link to={href!}>
<Icon />
<span>{label}</span>
{roles && !roles.includes(AmpRole.Viewer) && (
<Lock
className="ml-auto text-muted-foreground/70 shrink-0"
style={{ width: 11, height: 11 }}
/>
)}
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</CanAccess>
)
})}
{RESOURCE_NAV.map((item) => (
<ResourceNavItem
key={item.href ?? item.label}
item={item}
pathname={location.pathname}
canSeeNavItem={canSeeNavItem}
/>
))}
{/* ── TEMPORARY: Example section — hidden until editing-tools is ready ── */}
{/* <ExampleNavItem /> */}
</SidebarMenu>
Expand Down Expand Up @@ -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: /<prefix>/<slug>/show/<id> or /<prefix>/<slug>/edit/<id>
const DETAIL_PATTERN = /\/([a-z0-9-]+)\/(show|edit)\/([^/]+)$/

function NestedListBreadcrumb({
parentLabel,
parentHref,
label,
}: {
parentLabel: string
parentHref: string
label: string
}) {
return (
<nav aria-label="breadcrumb" className="flex items-center gap-1 text-sm shrink-0">
<Link
to={parentHref}
className="text-muted-foreground hover:text-foreground transition-colors no-underline"
>
{parentLabel}
</Link>
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground/40 shrink-0" aria-hidden="true" />
<span className="text-foreground font-medium">{label}</span>
</nav>
)
}

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] ?? ''
Expand All @@ -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<string, unknown> | undefined)?.name as
| string
| undefined

if (nestedList) {
return <NestedListBreadcrumb {...nestedList} />
}

if (!routeMatch || !resourceInfo) return null

const recordLabel = recordName ?? `#${id}`

return (
<nav aria-label="breadcrumb" className="flex items-center gap-1.5 text-sm shrink-0">
<nav aria-label="breadcrumb" className="flex items-center gap-1 text-sm shrink-0">
<Link
to={resourceInfo.listHref}
className="text-muted-foreground hover:text-foreground transition-colors no-underline"
Expand Down Expand Up @@ -978,7 +1139,7 @@ function ShellHeader() {
<HeaderBreadcrumb />
</div>
{/* Search bar — hidden on mobile, visible sm+ */}
<div className="hidden sm:block shrink-0 max-w-sm w-full ml-0">
<div className="hidden sm:block shrink-0 max-w-sm w-full sm:ml-3">
<SearchBar />
</div>
<div className="ml-auto flex items-center gap-1 shrink-0">
Expand Down
Loading
Loading