Scaffold the explorer layout (#48740)

## Context

Resolves FE-4074

Just adds scaffolding for the explorer UI - no data fetching yet.
Initializes the page + side nav, based off Saxon's POC in
`poc/explorer-prototype`

<img width="1389" height="500" alt="image"
src="https://github.com/user-attachments/assets/8f293992-97d9-403e-91d6-2e104cd20eb5"
/>


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

## New Features
- Added a project Explorer page accessible from
`/project/:ref/explorer`.
- Added navigation for browsing notebooks and chats.
- Added search fields, back navigation, animated transitions, and empty
states for Explorer sections.
- Added a conditional Explorer link to the SQL Editor menu when enabled.

## Documentation
- Marked the Explorer route migration as complete in the migration
checklist.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Joshen Lim authored and GitHub committed 2026-08-06 12:11:35 +07:00
1 parent ec24d69369
commit a7671019a8
13 files changed
+325 -7

No files matched your search

+1
View File
@@ -156,6 +156,7 @@ These are the layout-only TanStack files. Most hold a single product layout comp
- [x] A `routes/project/$ref/index.tsx` ← `pages/project/[ref]/index.tsx` (route wraps in `ProjectLayoutWithAuth` itself — see shell delta above)
- [x] `routes/project/$ref/merge.tsx` ← `pages/project/[ref]/merge.tsx` (leaf wraps body in `ProjectLayoutWithAuth`; parent `project/$ref.tsx` shell provides DefaultLayout)
- [x] A `routes/project/$ref/explorer.tsx` ← `pages/project/[ref]/explorer/index.tsx` (new placeholder page; leaf wraps body in `ProjectLayoutWithAuth`; parent `project/$ref.tsx` shell provides DefaultLayout)
### Project shell — `/api/*`
@@ -0,0 +1,20 @@
import { Compass } from 'lucide-react'
import { EmptyStatePresentational } from 'ui-patterns/EmptyStatePresentational'
import { PageContainer } from 'ui-patterns/PageContainer'
import { PageSection, PageSectionContent } from 'ui-patterns/PageSection'
export const ExplorerPage = () => {
return (
<PageContainer size="default">
<PageSection>
<PageSectionContent>
<EmptyStatePresentational
icon={Compass}
title="Explorer is coming soon"
description="This page is a placeholder for the Explorer feature."
/>
</PageSectionContent>
</PageSection>
</PageContainer>
)
}
@@ -0,0 +1,88 @@
import { motion } from 'framer-motion'
import { ChevronLeft, MessageSquare, NotebookText } from 'lucide-react'
import { type ComponentType, type PropsWithChildren } from 'react'
import { Button, cn } from 'ui'
import { InnerSideBarFilters, InnerSideBarFilterSearchInput } from 'ui-patterns/InnerSideMenu'
export type ExplorerResourceType = 'notebook' | 'chat'
export const LEVEL_OFFSET = 8
export const LEVEL_TRANSITION = { duration: 0.09, ease: 'easeOut' } as const
export const EXPLORER_SECTIONS: Array<{
type: ExplorerResourceType
label: string
icon: ComponentType<{ size?: number; className?: string }>
searchPlaceholder: string
}> = [
{
type: 'notebook',
label: 'Notebooks',
icon: NotebookText,
searchPlaceholder: 'Search notebooks',
},
{ type: 'chat', label: 'Chats', icon: MessageSquare, searchPlaceholder: 'Search chats' },
]
export const rowClassName = (isActive: boolean) =>
cn(
'group relative flex h-7 w-full items-center gap-2 rounded-md pl-3 pr-2 text-sm',
isActive
? 'bg-selection text-foreground'
: 'text-foreground-light hover:bg-surface-200 hover:text-foreground'
)
export const ExplorerNavResourceWrapper = ({
type,
label,
className,
children,
search,
setSearch,
onBack,
}: PropsWithChildren<{
type: ExplorerResourceType
label?: string
className?: string
search?: string
setSearch: (value: string) => void
onBack: () => void
}>) => {
const searchPlaceholder = EXPLORER_SECTIONS.find((x) => x.type === type)?.searchPlaceholder
return (
<motion.div
role="group"
aria-label={label}
initial={{ opacity: 0, x: LEVEL_OFFSET }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: LEVEL_OFFSET }}
transition={LEVEL_TRANSITION}
className={cn('absolute inset-0 flex flex-col', className)}
>
<div className="flex items-center gap-1 p-3 pb-2">
<Button
variant="outline"
size="tiny"
aria-label="Back to all resources"
onClick={onBack}
className="size-7 shrink-0 px-0"
icon={<ChevronLeft size={16} />}
/>
<span id="explorer-sidebar-search-label" className="sr-only">
{searchPlaceholder}
</span>
<InnerSideBarFilters className="w-full gap-0 p-0">
<InnerSideBarFilterSearchInput
name="explorer-sidebar-search"
value={search}
placeholder={searchPlaceholder}
aria-labelledby="explorer-sidebar-search-label"
onChange={(event) => setSearch(event.target.value)}
/>
</InnerSideBarFilters>
</div>
{children}
</motion.div>
)
}
@@ -0,0 +1,48 @@
import { AnimatePresence } from 'framer-motion'
import { ComponentProps, ReactNode, useState } from 'react'
import { ProjectLayoutWithAuth } from '../ProjectLayout'
import { type ExplorerResourceType } from './ExplorerLayout.constants'
import { ExplorerNavChats } from './ExplorerNavChats'
import { ExplorerNavHome } from './ExplorerNavHome'
import { ExplorerNavNotebooks } from './ExplorerNavNotebooks'
export interface ExplorerLayoutProps extends ComponentProps<typeof ProjectLayoutWithAuth> {
children: ReactNode
title?: string
}
export const ExplorerLayout = ({ browserTitle, children, title }: ExplorerLayoutProps) => {
const [section, setSection] = useState<ExplorerResourceType>()
// [Joshen] Temporary, to hook up with tabs store
const activeTabLabel = 'Active Tab Label'
const mergedBrowserTitle = {
...browserTitle,
section: title ?? browserTitle?.section,
entity: browserTitle?.entity ?? activeTabLabel,
}
return (
<ProjectLayoutWithAuth
product="Explorer"
browserTitle={mergedBrowserTitle}
productMenu={
<div className="relative h-full overflow-hidden">
<AnimatePresence mode="wait">
{section === undefined && <ExplorerNavHome key="home" onSelectSection={setSection} />}
{section === 'notebook' && (
<ExplorerNavNotebooks key="notebooks" onBack={() => setSection(undefined)} />
)}
{section === 'chat' && (
<ExplorerNavChats key="chats" onBack={() => setSection(undefined)} />
)}
</AnimatePresence>
</div>
}
>
{children}
</ProjectLayoutWithAuth>
)
}
@@ -0,0 +1,17 @@
import { useState } from 'react'
import { ExplorerNavResourceWrapper } from './ExplorerLayout.constants'
export const ExplorerNavChats = ({ onBack }: { onBack: () => void }) => {
const [search, setSearch] = useState('')
// [Joshen] Eventually will have data fetching for notebooks via useAiAssistantState
return (
<ExplorerNavResourceWrapper type="chat" search={search} setSearch={setSearch} onBack={onBack}>
<div className="flex flex-1 flex-col gap-px overflow-y-auto px-3 pb-3">
<p className="px-2 py-2 text-xs text-foreground-lighter">No chats created yet</p>
</div>
</ExplorerNavResourceWrapper>
)
}
@@ -0,0 +1,55 @@
import { motion } from 'framer-motion'
import { ChevronRight } from 'lucide-react'
import {
EXPLORER_SECTIONS,
ExplorerResourceType,
LEVEL_OFFSET,
LEVEL_TRANSITION,
rowClassName,
} from './ExplorerLayout.constants'
export const ExplorerNavHome = ({
onSelectSection,
}: {
onSelectSection: (section: ExplorerResourceType) => void
}) => {
return (
<motion.div
key="root"
role="group"
aria-label="All resources"
initial={{ opacity: 0, x: -LEVEL_OFFSET }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -LEVEL_OFFSET }}
transition={LEVEL_TRANSITION}
className="absolute inset-0 flex flex-col gap-4 overflow-y-auto p-3"
>
<nav className="flex flex-col gap-px">
{EXPLORER_SECTIONS.map(({ type, label, icon: Icon }) => {
return (
<button
key={type}
type="button"
tabIndex={0}
className={rowClassName(false)}
onClick={() => onSelectSection(type)}
>
<Icon size={14} className="shrink-0" />
<span className="flex-1 text-left">{label}</span>
<span className="text-xs text-foreground-lighter">{/* Length will be here */}</span>
<ChevronRight size={14} className="shrink-0 text-foreground-muted" />
</button>
)
})}
</nav>
<section className="flex flex-col gap-px">
<h3 className="mb-2 px-3 font-mono text-sm font-normal uppercase text-foreground-lighter">
Recent
</h3>
<p className="px-3 text-xs text-foreground-lighter">Nothing edited yet</p>
</section>
</motion.div>
)
}
@@ -0,0 +1,22 @@
import { useState } from 'react'
import { ExplorerNavResourceWrapper } from './ExplorerLayout.constants'
export const ExplorerNavNotebooks = ({ onBack }: { onBack: () => void }) => {
const [search, setSearch] = useState('')
// [Joshen] Eventually will have data fetching for notebooks via RQ
return (
<ExplorerNavResourceWrapper
type="notebook"
search={search}
setSearch={setSearch}
onBack={onBack}
>
<div className="flex flex-1 flex-col gap-px overflow-y-auto px-3 pb-3">
<p className="px-2 py-2 text-xs text-foreground-lighter">No notebooks created yet</p>
</div>
</ExplorerNavResourceWrapper>
)
}
@@ -1,12 +1,23 @@
import { useFlag, useParams } from 'common'
import Link from 'next/link'
import { PropsWithChildren } from 'react'
import { cn } from 'ui'
import { Button, cn } from 'ui'
interface ProductMenuBarProps {
title: string
className?: string
}
const ProductMenuBar = ({ title, children, className }: PropsWithChildren<ProductMenuBarProps>) => {
export const ProductMenuBar = ({
title,
children,
className,
}: PropsWithChildren<ProductMenuBarProps>) => {
// [Joshen] Temporary entry point into explorer
const { ref } = useParams()
const isExplorerEnabled = useFlag('explorer')
const showExplorerCTA = isExplorerEnabled && title === 'SQL Editor'
return (
<div
/**
@@ -18,12 +29,15 @@ const ProductMenuBar = ({ title, children, className }: PropsWithChildren<Produc
'hide-scrollbar bg-dash-sidebar border-default'
)}
>
<div className="border-default flex min-h-(--header-height) items-center border-b px-6">
<div className="border-default flex min-h-(--header-height) items-center border-b px-6 justify-between">
<h4 className="text-lg">{title}</h4>
{showExplorerCTA && (
<Button asChild variant="default">
<Link href={`/project/${ref}/explorer`}>Explorer</Link>
</Button>
)}
</div>
<div className={cn('grow overflow-y-auto', className)}>{children}</div>
</div>
)
}
export default ProductMenuBar
@@ -138,7 +138,7 @@ vi.mock('./PausedState/ProjectPausedState', () => ({ ProjectPausedState: () => n
vi.mock('./PauseFailedState', () => ({ PauseFailedState: () => null }))
vi.mock('./PausingState', () => ({ PausingState: () => null }))
vi.mock('./ProductMenuBar', () => ({
default: ({ children }: { children: ReactNode }) => <>{children}</>,
ProductMenuBar: ({ children }: { children: ReactNode }) => <>{children}</>,
}))
vi.mock('./ResizingState', () => ({ ResizingState: () => null }))
vi.mock('./RestartingState', () => ({ default: () => null }))
@@ -27,7 +27,7 @@ import {
import { useEditorType } from '../editors/EditorsLayout.hooks'
import { useMainScrollContainer, useSetMainScrollContainer } from '../MainScrollContainerContext'
import { useMobileSheet } from '../Navigation/NavigationBar/MobileSheetContext'
import ProductMenuBar from '../Navigation/ProductMenuBar'
import { ProductMenuBar } from '../Navigation/ProductMenuBar'
import BuildingState from './BuildingState'
import ConnectingState from './ConnectingState'
import { getSectionKeyFromPathname, MobileMenuContent } from './LayoutHeader/MobileMenuContent'
@@ -0,0 +1,16 @@
import { ExplorerPage } from '@/components/interfaces/Explorer/ExplorerPage'
import { DefaultLayout } from '@/components/layouts/DefaultLayout'
import { ExplorerLayout } from '@/components/layouts/ExplorerLayout/ExplorerLayout'
import type { NextPageWithLayout } from '@/types'
const ProjectExplorerPage: NextPageWithLayout = () => {
return <ExplorerPage />
}
ProjectExplorerPage.getLayout = (page) => (
<DefaultLayout>
<ExplorerLayout>{page}</ExplorerLayout>
</DefaultLayout>
)
export default ProjectExplorerPage
+21
View File
@@ -63,6 +63,7 @@ import { Route as ProjectRefMergeRouteImport } from './routes/project/$ref/merge
import { Route as ProjectRefLogsRouteImport } from './routes/project/$ref/logs'
import { Route as ProjectRefIntegrationsRouteImport } from './routes/project/$ref/integrations'
import { Route as ProjectRefFunctionsRouteImport } from './routes/project/$ref/functions'
import { Route as ProjectRefExplorerRouteImport } from './routes/project/$ref/explorer'
import { Route as ProjectRefEditorRouteImport } from './routes/project/$ref/editor'
import { Route as ProjectRefDatabaseRouteImport } from './routes/project/$ref/database'
import { Route as ProjectRefBranchesRouteImport } from './routes/project/$ref/branches'
@@ -588,6 +589,11 @@ const ProjectRefFunctionsRoute = ProjectRefFunctionsRouteImport.update({
path: '/functions',
getParentRoute: () => ProjectRefRoute,
} as any)
const ProjectRefExplorerRoute = ProjectRefExplorerRouteImport.update({
id: '/explorer',
path: '/explorer',
getParentRoute: () => ProjectRefRoute,
} as any)
const ProjectRefEditorRoute = ProjectRefEditorRouteImport.update({
id: '/editor',
path: '/editor',
@@ -2085,6 +2091,7 @@ export interface FileRoutesByFullPath {
'/project/$ref/branches': typeof ProjectRefBranchesRouteWithChildren
'/project/$ref/database': typeof ProjectRefDatabaseRouteWithChildren
'/project/$ref/editor': typeof ProjectRefEditorRouteWithChildren
'/project/$ref/explorer': typeof ProjectRefExplorerRoute
'/project/$ref/functions': typeof ProjectRefFunctionsRouteWithChildren
'/project/$ref/integrations': typeof ProjectRefIntegrationsRouteWithChildren
'/project/$ref/logs': typeof ProjectRefLogsRouteWithChildren
@@ -2387,6 +2394,7 @@ export interface FileRoutesByTo {
'/project/$ref/advisors': typeof ProjectRefAdvisorsRouteWithChildren
'/project/$ref/auth': typeof ProjectRefAuthRouteWithChildren
'/project/$ref/database': typeof ProjectRefDatabaseRouteWithChildren
'/project/$ref/explorer': typeof ProjectRefExplorerRoute
'/project/$ref/merge': typeof ProjectRefMergeRoute
'/project/$ref/realtime': typeof ProjectRefRealtimeRouteWithChildren
'/project/$ref/settings': typeof ProjectRefSettingsRouteWithChildren
@@ -2688,6 +2696,7 @@ export interface FileRoutesById {
'/project/$ref/branches': typeof ProjectRefBranchesRouteWithChildren
'/project/$ref/database': typeof ProjectRefDatabaseRouteWithChildren
'/project/$ref/editor': typeof ProjectRefEditorRouteWithChildren
'/project/$ref/explorer': typeof ProjectRefExplorerRoute
'/project/$ref/functions': typeof ProjectRefFunctionsRouteWithChildren
'/project/$ref/integrations': typeof ProjectRefIntegrationsRouteWithChildren
'/project/$ref/logs': typeof ProjectRefLogsRouteWithChildren
@@ -2996,6 +3005,7 @@ export interface FileRouteTypes {
| '/project/$ref/branches'
| '/project/$ref/database'
| '/project/$ref/editor'
| '/project/$ref/explorer'
| '/project/$ref/functions'
| '/project/$ref/integrations'
| '/project/$ref/logs'
@@ -3298,6 +3308,7 @@ export interface FileRouteTypes {
| '/project/$ref/advisors'
| '/project/$ref/auth'
| '/project/$ref/database'
| '/project/$ref/explorer'
| '/project/$ref/merge'
| '/project/$ref/realtime'
| '/project/$ref/settings'
@@ -3598,6 +3609,7 @@ export interface FileRouteTypes {
| '/project/$ref/branches'
| '/project/$ref/database'
| '/project/$ref/editor'
| '/project/$ref/explorer'
| '/project/$ref/functions'
| '/project/$ref/integrations'
| '/project/$ref/logs'
@@ -4350,6 +4362,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ProjectRefFunctionsRouteImport
parentRoute: typeof ProjectRefRoute
}
'/project/$ref/explorer': {
id: '/project/$ref/explorer'
path: '/explorer'
fullPath: '/project/$ref/explorer'
preLoaderRoute: typeof ProjectRefExplorerRouteImport
parentRoute: typeof ProjectRefRoute
}
'/project/$ref/editor': {
id: '/project/$ref/editor'
path: '/editor'
@@ -6716,6 +6735,7 @@ interface ProjectRefRouteChildren {
ProjectRefBranchesRoute: typeof ProjectRefBranchesRouteWithChildren
ProjectRefDatabaseRoute: typeof ProjectRefDatabaseRouteWithChildren
ProjectRefEditorRoute: typeof ProjectRefEditorRouteWithChildren
ProjectRefExplorerRoute: typeof ProjectRefExplorerRoute
ProjectRefFunctionsRoute: typeof ProjectRefFunctionsRouteWithChildren
ProjectRefIntegrationsRoute: typeof ProjectRefIntegrationsRouteWithChildren
ProjectRefLogsRoute: typeof ProjectRefLogsRouteWithChildren
@@ -6735,6 +6755,7 @@ const ProjectRefRouteChildren: ProjectRefRouteChildren = {
ProjectRefBranchesRoute: ProjectRefBranchesRouteWithChildren,
ProjectRefDatabaseRoute: ProjectRefDatabaseRouteWithChildren,
ProjectRefEditorRoute: ProjectRefEditorRouteWithChildren,
ProjectRefExplorerRoute: ProjectRefExplorerRoute,
ProjectRefFunctionsRoute: ProjectRefFunctionsRouteWithChildren,
ProjectRefIntegrationsRoute: ProjectRefIntegrationsRouteWithChildren,
ProjectRefLogsRoute: ProjectRefLogsRouteWithChildren,
@@ -0,0 +1,16 @@
import { createFileRoute } from '@tanstack/react-router'
import { ExplorerPage } from '@/components/interfaces/Explorer/ExplorerPage'
import { ExplorerLayout } from '@/components/layouts/ExplorerLayout/ExplorerLayout'
export const Route = createFileRoute('/project/$ref/explorer')({
component: ProjectExplorerRoute,
})
function ProjectExplorerRoute() {
return (
<ExplorerLayout>
<ExplorerPage />
</ExplorerLayout>
)
}