mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
Initialize notebooks store (#48801)
## Context More groundwork for the Explorer - this one's focused on initializing the valtio store for managing notebooks Store architecture will follow closely with the existing sql-editor-store No data persistence yet, but can test creating a new notebook <img width="195" height="143" alt="image" src="https://github.com/user-attachments/assets/8656fb5b-3a8e-4f71-b2ce-d2f34ca9b552" /> Which should open a placeholder page <img width="1387" height="527" alt="image" src="https://github.com/user-attachments/assets/4a81b1ae-a740-40e6-9d33-29fa4f83b541" /> Closing the notebook brings you back to the explorer home page <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added support for creating and opening project notebooks from the Explorer. * Added notebook tabs alongside existing entity and SQL tabs. * Added notebook management, including loading, renaming, removing, editing cells, and tracking unsaved changes. * Added support for SQL, logs, and Markdown notebook cells. * Added dedicated notebook routes and an initial notebook editor view. * Added notebook icons throughout the Explorer interface. * **Documentation** * Documented session-scoped notebook state for query results and row limits. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
1 parent
91a394fc83
commit
3a383c87c7
16 files changed
+510
-16
No files matched your search
@@ -156,7 +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)
|
||||
- [x] `routes/project/$ref/explorer.tsx` — converted from a leaf into a shell (`ExplorerLayout` + `Outlet`) to host the new `/explorer/notebook/$id` leaf; parent `project/$ref.tsx` shell still provides DefaultLayout.
|
||||
|
||||
### Project shell — `/api/*`
|
||||
|
||||
@@ -322,6 +322,11 @@ These are the layout-only TanStack files. Most hold a single product layout comp
|
||||
- [x] A `routes/project/$ref/editor/$id.tsx` ← `pages/project/[ref]/editor/[id].tsx`
|
||||
- [x] A `routes/project/$ref/editor/new.tsx` ← `pages/project/[ref]/editor/new.tsx`
|
||||
|
||||
### Project shell — `/explorer/*`
|
||||
|
||||
- [x] A `routes/project/$ref/explorer/index.tsx` ← `pages/project/[ref]/explorer/index.tsx`
|
||||
- [x] A `routes/project/$ref/explorer/notebook/$id.tsx` ← `pages/project/[ref]/explorer/notebook/[id].tsx`
|
||||
|
||||
### Auth shell — `/sign-in`, `/sign-up`, etc.
|
||||
|
||||
- [x] A `routes/_auth/sign-in.tsx` ← `pages/sign-in.tsx`
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useParams } from 'common'
|
||||
import { useEffect, useEffectEvent } from 'react'
|
||||
|
||||
import { useNotebooksStateSnapshot } from '@/state/notebooks/notebooks-state'
|
||||
import { createTabId, useTabsStateSnapshot } from '@/state/tabs'
|
||||
|
||||
export const NotebookEditor = () => {
|
||||
const { id } = useParams()
|
||||
const tabs = useTabsStateSnapshot()
|
||||
const snap = useNotebooksStateSnapshot()
|
||||
const stateNotebook = id ? snap.notebooks[id] : undefined
|
||||
|
||||
const registerTab = useEffectEvent(() => {
|
||||
if (!id) return
|
||||
tabs.addTab({
|
||||
id: createTabId('notebook', { id }),
|
||||
type: 'notebook',
|
||||
label: stateNotebook?.notebook.name ?? 'New Notebook',
|
||||
metadata: { notebookId: id },
|
||||
})
|
||||
})
|
||||
|
||||
useEffect(() => registerTab(), [id])
|
||||
|
||||
return <div>This is a notebook</div>
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { NotebookEditor } from '../NotebookEditor'
|
||||
import { notebooksState } from '@/state/notebooks/notebooks-state'
|
||||
import type { Notebook } from '@/state/notebooks/types'
|
||||
|
||||
const { mockUseParams, mockAddTab } = vi.hoisted(() => ({
|
||||
mockUseParams: vi.fn(),
|
||||
mockAddTab: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('common', () => ({
|
||||
useParams: () => mockUseParams(),
|
||||
}))
|
||||
|
||||
vi.mock('@/state/tabs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/state/tabs')>()
|
||||
return {
|
||||
...actual,
|
||||
useTabsStateSnapshot: () => ({ addTab: mockAddTab }),
|
||||
}
|
||||
})
|
||||
|
||||
function makeNotebook(id: string, overrides: Partial<Notebook> = {}): Notebook {
|
||||
return {
|
||||
id,
|
||||
type: 'notebook',
|
||||
name: 'My Notebook',
|
||||
description: '',
|
||||
visibility: 'project',
|
||||
favorite: false,
|
||||
owner_id: 7,
|
||||
project_id: 42,
|
||||
content: { schema_version: '1.0', cells: [] },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('NotebookEditor tab registration', () => {
|
||||
beforeEach(() => {
|
||||
mockAddTab.mockClear()
|
||||
mockUseParams.mockReturnValue({ ref: 'default', id: 'notebook-1' })
|
||||
|
||||
// notebooksState is a module-level singleton, so reset the state these tests touch
|
||||
for (const id of Object.keys(notebooksState.notebooks)) {
|
||||
delete notebooksState.notebooks[id]
|
||||
}
|
||||
notebooksState.needsSaving.clear()
|
||||
})
|
||||
|
||||
it('registers a tab with the notebook id, type, loaded name, and metadata', () => {
|
||||
notebooksState.setNotebook({
|
||||
projectRef: 'default',
|
||||
notebook: makeNotebook('notebook-1', { name: 'My Notebook' }),
|
||||
})
|
||||
|
||||
render(<NotebookEditor />)
|
||||
|
||||
expect(mockAddTab).toHaveBeenCalledTimes(1)
|
||||
expect(mockAddTab).toHaveBeenCalledWith({
|
||||
id: 'notebook-notebook-1',
|
||||
type: 'notebook',
|
||||
label: 'My Notebook',
|
||||
metadata: { notebookId: 'notebook-1' },
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to "New Notebook" as the label when the notebook has not loaded yet', () => {
|
||||
render(<NotebookEditor />)
|
||||
|
||||
expect(mockAddTab).toHaveBeenCalledWith({
|
||||
id: 'notebook-notebook-1',
|
||||
type: 'notebook',
|
||||
label: 'New Notebook',
|
||||
metadata: { notebookId: 'notebook-1' },
|
||||
})
|
||||
})
|
||||
|
||||
it('does not register a tab when there is no id in the route', () => {
|
||||
mockUseParams.mockReturnValue({ ref: 'default', id: undefined })
|
||||
|
||||
render(<NotebookEditor />)
|
||||
|
||||
expect(mockAddTab).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
import { generateUuid } from '@/lib/api/snippets.browser'
|
||||
import { useProfile } from '@/lib/profile'
|
||||
import { useNotebooksStateSnapshot } from '@/state/notebooks/notebooks-state'
|
||||
import { type Notebook } from '@/state/notebooks/types'
|
||||
|
||||
export const useCreateNotebook = () => {
|
||||
const router = useRouter()
|
||||
const { profile } = useProfile()
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const notebooksSnap = useNotebooksStateSnapshot()
|
||||
|
||||
const createNotebook = ({ id: idOverride, name }: { id?: string; name?: string } = {}) => {
|
||||
if (!profile) return console.error('Profile is required')
|
||||
if (!project) return console.error('Project is required')
|
||||
|
||||
const id = idOverride ?? generateUuid()
|
||||
|
||||
const notebook: Notebook = {
|
||||
id,
|
||||
type: 'notebook',
|
||||
name: name ?? 'New Notebook',
|
||||
description: '',
|
||||
visibility: 'project',
|
||||
favorite: false,
|
||||
content: {
|
||||
schema_version: '1.0',
|
||||
cells: [],
|
||||
},
|
||||
owner_id: profile.id,
|
||||
project_id: project.id,
|
||||
}
|
||||
|
||||
notebooksSnap.addNotebook({ projectRef: project.ref, notebook })
|
||||
notebooksSnap.addNeedsSaving(notebook.id)
|
||||
|
||||
router.push(`/project/${project.ref}/explorer/notebook/${notebook.id}`)
|
||||
}
|
||||
|
||||
return { createNotebook }
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { type ExplorerResourceType } from './ExplorerLayout.constants'
|
||||
import { ExplorerNavChats } from './ExplorerNavChats'
|
||||
import { ExplorerNavHome } from './ExplorerNavHome'
|
||||
import { ExplorerNavNotebooks } from './ExplorerNavNotebooks'
|
||||
import { useCreateNotebook } from '@/components/interfaces/Explorer/hooks'
|
||||
|
||||
export interface ExplorerLayoutProps extends ComponentProps<typeof ProjectLayoutWithAuth> {
|
||||
children: ReactNode
|
||||
@@ -85,6 +86,8 @@ const HomeTabButton = () => {
|
||||
}
|
||||
|
||||
const NewTabButton = () => {
|
||||
const { createNotebook } = useCreateNotebook()
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -103,7 +106,7 @@ const NewTabButton = () => {
|
||||
</motion.button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-40" align="end">
|
||||
<DropdownMenuItem className="gap-x-2">
|
||||
<DropdownMenuItem className="gap-x-2" onClick={() => createNotebook()}>
|
||||
<NotebookText size={14} />
|
||||
<span>New notebook</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Eye, GitBranch, ScrollText, Table2 } from 'lucide-react'
|
||||
import { Eye, GitBranch, NotebookText, ScrollText, Table2 } from 'lucide-react'
|
||||
import { cn, SQL_ICON } from 'ui'
|
||||
|
||||
import type { SqlSnippetSource } from '@/components/interfaces/SQLEditor/querySource'
|
||||
@@ -26,7 +26,7 @@ export const LogsSnippetIcon = ({
|
||||
)
|
||||
|
||||
interface EntityTypeIconProps {
|
||||
type: 'sql' | 'schema' | 'new' | 'r' | 'v' | 'm' | 'f' | 'p'
|
||||
type: 'sql' | 'schema' | 'new' | 'r' | 'v' | 'm' | 'f' | 'p' | 'notebook'
|
||||
size?: number
|
||||
strokeWidth?: number
|
||||
isActive?: boolean
|
||||
@@ -103,6 +103,10 @@ export const EntityTypeIcon = ({
|
||||
)
|
||||
}
|
||||
|
||||
if (type === 'notebook') {
|
||||
return <NotebookText size={size} strokeWidth={strokeWidth} className={''} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { NotebookEditor } from '@/components/interfaces/Explorer/NotebookEditor'
|
||||
import { DefaultLayout } from '@/components/layouts/DefaultLayout'
|
||||
import { ExplorerLayout } from '@/components/layouts/ExplorerLayout/ExplorerLayout'
|
||||
import type { NextPageWithLayout } from '@/types'
|
||||
|
||||
const NotebookPage: NextPageWithLayout = () => {
|
||||
return <NotebookEditor />
|
||||
}
|
||||
|
||||
NotebookPage.getLayout = (page) => (
|
||||
<DefaultLayout>
|
||||
<ExplorerLayout>{page}</ExplorerLayout>
|
||||
</DefaultLayout>
|
||||
)
|
||||
|
||||
export default NotebookPage
|
||||
@@ -89,6 +89,7 @@ import { Route as ProjectRefObservabilityIndexRouteImport } from './routes/proje
|
||||
import { Route as ProjectRefLogsIndexRouteImport } from './routes/project/$ref/logs/index'
|
||||
import { Route as ProjectRefIntegrationsIndexRouteImport } from './routes/project/$ref/integrations/index'
|
||||
import { Route as ProjectRefFunctionsIndexRouteImport } from './routes/project/$ref/functions/index'
|
||||
import { Route as ProjectRefExplorerIndexRouteImport } from './routes/project/$ref/explorer/index'
|
||||
import { Route as ProjectRefEditorIndexRouteImport } from './routes/project/$ref/editor/index'
|
||||
import { Route as ProjectRefBranchesIndexRouteImport } from './routes/project/$ref/branches/index'
|
||||
import { Route as ProjectRefApiIndexRouteImport } from './routes/project/$ref/api/index'
|
||||
@@ -228,6 +229,7 @@ import { Route as ProjectRefFunctionsFunctionSlugLogsRouteImport } from './route
|
||||
import { Route as ProjectRefFunctionsFunctionSlugInvocationsRouteImport } from './routes/project/$ref/functions/$functionSlug/invocations'
|
||||
import { Route as ProjectRefFunctionsFunctionSlugDetailsRouteImport } from './routes/project/$ref/functions/$functionSlug/details'
|
||||
import { Route as ProjectRefFunctionsFunctionSlugCodeRouteImport } from './routes/project/$ref/functions/$functionSlug/code'
|
||||
import { Route as ProjectRefExplorerNotebookIdRouteImport } from './routes/project/$ref/explorer/notebook/$id'
|
||||
import { Route as ProjectRefDatabaseTriggersEventRouteImport } from './routes/project/$ref/database/triggers/event'
|
||||
import { Route as ProjectRefDatabaseTriggersDataRouteImport } from './routes/project/$ref/database/triggers/data'
|
||||
import { Route as ProjectRefDatabaseTablesIdRouteImport } from './routes/project/$ref/database/tables/$id'
|
||||
@@ -728,6 +730,11 @@ const ProjectRefFunctionsIndexRoute =
|
||||
path: '/',
|
||||
getParentRoute: () => ProjectRefFunctionsRoute,
|
||||
} as any)
|
||||
const ProjectRefExplorerIndexRoute = ProjectRefExplorerIndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => ProjectRefExplorerRoute,
|
||||
} as any)
|
||||
const ProjectRefEditorIndexRoute = ProjectRefEditorIndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
@@ -1509,6 +1516,12 @@ const ProjectRefFunctionsFunctionSlugCodeRoute =
|
||||
path: '/code',
|
||||
getParentRoute: () => ProjectRefFunctionsFunctionSlugRoute,
|
||||
} as any)
|
||||
const ProjectRefExplorerNotebookIdRoute =
|
||||
ProjectRefExplorerNotebookIdRouteImport.update({
|
||||
id: '/notebook/$id',
|
||||
path: '/notebook/$id',
|
||||
getParentRoute: () => ProjectRefExplorerRoute,
|
||||
} as any)
|
||||
const ProjectRefDatabaseTriggersEventRoute =
|
||||
ProjectRefDatabaseTriggersEventRouteImport.update({
|
||||
id: '/event',
|
||||
@@ -2099,7 +2112,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/explorer': typeof ProjectRefExplorerRouteWithChildren
|
||||
'/project/$ref/functions': typeof ProjectRefFunctionsRouteWithChildren
|
||||
'/project/$ref/integrations': typeof ProjectRefIntegrationsRouteWithChildren
|
||||
'/project/$ref/logs': typeof ProjectRefLogsRouteWithChildren
|
||||
@@ -2224,6 +2237,7 @@ export interface FileRoutesByFullPath {
|
||||
'/project/$ref/api/': typeof ProjectRefApiIndexRoute
|
||||
'/project/$ref/branches/': typeof ProjectRefBranchesIndexRoute
|
||||
'/project/$ref/editor/': typeof ProjectRefEditorIndexRoute
|
||||
'/project/$ref/explorer/': typeof ProjectRefExplorerIndexRoute
|
||||
'/project/$ref/functions/': typeof ProjectRefFunctionsIndexRoute
|
||||
'/project/$ref/integrations/': typeof ProjectRefIntegrationsIndexRoute
|
||||
'/project/$ref/logs/': typeof ProjectRefLogsIndexRoute
|
||||
@@ -2268,6 +2282,7 @@ export interface FileRoutesByFullPath {
|
||||
'/project/$ref/database/tables/$id': typeof ProjectRefDatabaseTablesIdRoute
|
||||
'/project/$ref/database/triggers/data': typeof ProjectRefDatabaseTriggersDataRoute
|
||||
'/project/$ref/database/triggers/event': typeof ProjectRefDatabaseTriggersEventRoute
|
||||
'/project/$ref/explorer/notebook/$id': typeof ProjectRefExplorerNotebookIdRoute
|
||||
'/project/$ref/functions/$functionSlug/code': typeof ProjectRefFunctionsFunctionSlugCodeRoute
|
||||
'/project/$ref/functions/$functionSlug/details': typeof ProjectRefFunctionsFunctionSlugDetailsRoute
|
||||
'/project/$ref/functions/$functionSlug/invocations': typeof ProjectRefFunctionsFunctionSlugInvocationsRoute
|
||||
@@ -2403,7 +2418,6 @@ 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
|
||||
@@ -2520,6 +2534,7 @@ export interface FileRoutesByTo {
|
||||
'/project/$ref/api': typeof ProjectRefApiIndexRoute
|
||||
'/project/$ref/branches': typeof ProjectRefBranchesIndexRoute
|
||||
'/project/$ref/editor': typeof ProjectRefEditorIndexRoute
|
||||
'/project/$ref/explorer': typeof ProjectRefExplorerIndexRoute
|
||||
'/project/$ref/functions': typeof ProjectRefFunctionsIndexRoute
|
||||
'/project/$ref/integrations': typeof ProjectRefIntegrationsIndexRoute
|
||||
'/project/$ref/logs': typeof ProjectRefLogsIndexRoute
|
||||
@@ -2564,6 +2579,7 @@ export interface FileRoutesByTo {
|
||||
'/project/$ref/database/tables/$id': typeof ProjectRefDatabaseTablesIdRoute
|
||||
'/project/$ref/database/triggers/data': typeof ProjectRefDatabaseTriggersDataRoute
|
||||
'/project/$ref/database/triggers/event': typeof ProjectRefDatabaseTriggersEventRoute
|
||||
'/project/$ref/explorer/notebook/$id': typeof ProjectRefExplorerNotebookIdRoute
|
||||
'/project/$ref/functions/$functionSlug/code': typeof ProjectRefFunctionsFunctionSlugCodeRoute
|
||||
'/project/$ref/functions/$functionSlug/details': typeof ProjectRefFunctionsFunctionSlugDetailsRoute
|
||||
'/project/$ref/functions/$functionSlug/invocations': typeof ProjectRefFunctionsFunctionSlugInvocationsRoute
|
||||
@@ -2706,7 +2722,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/explorer': typeof ProjectRefExplorerRouteWithChildren
|
||||
'/project/$ref/functions': typeof ProjectRefFunctionsRouteWithChildren
|
||||
'/project/$ref/integrations': typeof ProjectRefIntegrationsRouteWithChildren
|
||||
'/project/$ref/logs': typeof ProjectRefLogsRouteWithChildren
|
||||
@@ -2831,6 +2847,7 @@ export interface FileRoutesById {
|
||||
'/project/$ref/api/': typeof ProjectRefApiIndexRoute
|
||||
'/project/$ref/branches/': typeof ProjectRefBranchesIndexRoute
|
||||
'/project/$ref/editor/': typeof ProjectRefEditorIndexRoute
|
||||
'/project/$ref/explorer/': typeof ProjectRefExplorerIndexRoute
|
||||
'/project/$ref/functions/': typeof ProjectRefFunctionsIndexRoute
|
||||
'/project/$ref/integrations/': typeof ProjectRefIntegrationsIndexRoute
|
||||
'/project/$ref/logs/': typeof ProjectRefLogsIndexRoute
|
||||
@@ -2875,6 +2892,7 @@ export interface FileRoutesById {
|
||||
'/project/$ref/database/tables/$id': typeof ProjectRefDatabaseTablesIdRoute
|
||||
'/project/$ref/database/triggers/data': typeof ProjectRefDatabaseTriggersDataRoute
|
||||
'/project/$ref/database/triggers/event': typeof ProjectRefDatabaseTriggersEventRoute
|
||||
'/project/$ref/explorer/notebook/$id': typeof ProjectRefExplorerNotebookIdRoute
|
||||
'/project/$ref/functions/$functionSlug/code': typeof ProjectRefFunctionsFunctionSlugCodeRoute
|
||||
'/project/$ref/functions/$functionSlug/details': typeof ProjectRefFunctionsFunctionSlugDetailsRoute
|
||||
'/project/$ref/functions/$functionSlug/invocations': typeof ProjectRefFunctionsFunctionSlugInvocationsRoute
|
||||
@@ -3141,6 +3159,7 @@ export interface FileRouteTypes {
|
||||
| '/project/$ref/api/'
|
||||
| '/project/$ref/branches/'
|
||||
| '/project/$ref/editor/'
|
||||
| '/project/$ref/explorer/'
|
||||
| '/project/$ref/functions/'
|
||||
| '/project/$ref/integrations/'
|
||||
| '/project/$ref/logs/'
|
||||
@@ -3185,6 +3204,7 @@ export interface FileRouteTypes {
|
||||
| '/project/$ref/database/tables/$id'
|
||||
| '/project/$ref/database/triggers/data'
|
||||
| '/project/$ref/database/triggers/event'
|
||||
| '/project/$ref/explorer/notebook/$id'
|
||||
| '/project/$ref/functions/$functionSlug/code'
|
||||
| '/project/$ref/functions/$functionSlug/details'
|
||||
| '/project/$ref/functions/$functionSlug/invocations'
|
||||
@@ -3320,7 +3340,6 @@ export interface FileRouteTypes {
|
||||
| '/project/$ref/advisors'
|
||||
| '/project/$ref/auth'
|
||||
| '/project/$ref/database'
|
||||
| '/project/$ref/explorer'
|
||||
| '/project/$ref/merge'
|
||||
| '/project/$ref/realtime'
|
||||
| '/project/$ref/settings'
|
||||
@@ -3437,6 +3456,7 @@ export interface FileRouteTypes {
|
||||
| '/project/$ref/api'
|
||||
| '/project/$ref/branches'
|
||||
| '/project/$ref/editor'
|
||||
| '/project/$ref/explorer'
|
||||
| '/project/$ref/functions'
|
||||
| '/project/$ref/integrations'
|
||||
| '/project/$ref/logs'
|
||||
@@ -3481,6 +3501,7 @@ export interface FileRouteTypes {
|
||||
| '/project/$ref/database/tables/$id'
|
||||
| '/project/$ref/database/triggers/data'
|
||||
| '/project/$ref/database/triggers/event'
|
||||
| '/project/$ref/explorer/notebook/$id'
|
||||
| '/project/$ref/functions/$functionSlug/code'
|
||||
| '/project/$ref/functions/$functionSlug/details'
|
||||
| '/project/$ref/functions/$functionSlug/invocations'
|
||||
@@ -3747,6 +3768,7 @@ export interface FileRouteTypes {
|
||||
| '/project/$ref/api/'
|
||||
| '/project/$ref/branches/'
|
||||
| '/project/$ref/editor/'
|
||||
| '/project/$ref/explorer/'
|
||||
| '/project/$ref/functions/'
|
||||
| '/project/$ref/integrations/'
|
||||
| '/project/$ref/logs/'
|
||||
@@ -3791,6 +3813,7 @@ export interface FileRouteTypes {
|
||||
| '/project/$ref/database/tables/$id'
|
||||
| '/project/$ref/database/triggers/data'
|
||||
| '/project/$ref/database/triggers/event'
|
||||
| '/project/$ref/explorer/notebook/$id'
|
||||
| '/project/$ref/functions/$functionSlug/code'
|
||||
| '/project/$ref/functions/$functionSlug/details'
|
||||
| '/project/$ref/functions/$functionSlug/invocations'
|
||||
@@ -4558,6 +4581,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof ProjectRefFunctionsIndexRouteImport
|
||||
parentRoute: typeof ProjectRefFunctionsRoute
|
||||
}
|
||||
'/project/$ref/explorer/': {
|
||||
id: '/project/$ref/explorer/'
|
||||
path: '/'
|
||||
fullPath: '/project/$ref/explorer/'
|
||||
preLoaderRoute: typeof ProjectRefExplorerIndexRouteImport
|
||||
parentRoute: typeof ProjectRefExplorerRoute
|
||||
}
|
||||
'/project/$ref/editor/': {
|
||||
id: '/project/$ref/editor/'
|
||||
path: '/'
|
||||
@@ -5531,6 +5561,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof ProjectRefFunctionsFunctionSlugCodeRouteImport
|
||||
parentRoute: typeof ProjectRefFunctionsFunctionSlugRoute
|
||||
}
|
||||
'/project/$ref/explorer/notebook/$id': {
|
||||
id: '/project/$ref/explorer/notebook/$id'
|
||||
path: '/notebook/$id'
|
||||
fullPath: '/project/$ref/explorer/notebook/$id'
|
||||
preLoaderRoute: typeof ProjectRefExplorerNotebookIdRouteImport
|
||||
parentRoute: typeof ProjectRefExplorerRoute
|
||||
}
|
||||
'/project/$ref/database/triggers/event': {
|
||||
id: '/project/$ref/database/triggers/event'
|
||||
path: '/event'
|
||||
@@ -6477,6 +6514,19 @@ const ProjectRefEditorRouteChildren: ProjectRefEditorRouteChildren = {
|
||||
const ProjectRefEditorRouteWithChildren =
|
||||
ProjectRefEditorRoute._addFileChildren(ProjectRefEditorRouteChildren)
|
||||
|
||||
interface ProjectRefExplorerRouteChildren {
|
||||
ProjectRefExplorerIndexRoute: typeof ProjectRefExplorerIndexRoute
|
||||
ProjectRefExplorerNotebookIdRoute: typeof ProjectRefExplorerNotebookIdRoute
|
||||
}
|
||||
|
||||
const ProjectRefExplorerRouteChildren: ProjectRefExplorerRouteChildren = {
|
||||
ProjectRefExplorerIndexRoute: ProjectRefExplorerIndexRoute,
|
||||
ProjectRefExplorerNotebookIdRoute: ProjectRefExplorerNotebookIdRoute,
|
||||
}
|
||||
|
||||
const ProjectRefExplorerRouteWithChildren =
|
||||
ProjectRefExplorerRoute._addFileChildren(ProjectRefExplorerRouteChildren)
|
||||
|
||||
interface ProjectRefFunctionsFunctionSlugRouteChildren {
|
||||
ProjectRefFunctionsFunctionSlugCodeRoute: typeof ProjectRefFunctionsFunctionSlugCodeRoute
|
||||
ProjectRefFunctionsFunctionSlugDetailsRoute: typeof ProjectRefFunctionsFunctionSlugDetailsRoute
|
||||
@@ -6756,7 +6806,7 @@ interface ProjectRefRouteChildren {
|
||||
ProjectRefBranchesRoute: typeof ProjectRefBranchesRouteWithChildren
|
||||
ProjectRefDatabaseRoute: typeof ProjectRefDatabaseRouteWithChildren
|
||||
ProjectRefEditorRoute: typeof ProjectRefEditorRouteWithChildren
|
||||
ProjectRefExplorerRoute: typeof ProjectRefExplorerRoute
|
||||
ProjectRefExplorerRoute: typeof ProjectRefExplorerRouteWithChildren
|
||||
ProjectRefFunctionsRoute: typeof ProjectRefFunctionsRouteWithChildren
|
||||
ProjectRefIntegrationsRoute: typeof ProjectRefIntegrationsRouteWithChildren
|
||||
ProjectRefLogsRoute: typeof ProjectRefLogsRouteWithChildren
|
||||
@@ -6776,7 +6826,7 @@ const ProjectRefRouteChildren: ProjectRefRouteChildren = {
|
||||
ProjectRefBranchesRoute: ProjectRefBranchesRouteWithChildren,
|
||||
ProjectRefDatabaseRoute: ProjectRefDatabaseRouteWithChildren,
|
||||
ProjectRefEditorRoute: ProjectRefEditorRouteWithChildren,
|
||||
ProjectRefExplorerRoute: ProjectRefExplorerRoute,
|
||||
ProjectRefExplorerRoute: ProjectRefExplorerRouteWithChildren,
|
||||
ProjectRefFunctionsRoute: ProjectRefFunctionsRouteWithChildren,
|
||||
ProjectRefIntegrationsRoute: ProjectRefIntegrationsRouteWithChildren,
|
||||
ProjectRefLogsRoute: ProjectRefLogsRouteWithChildren,
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { createFileRoute, Outlet } from '@tanstack/react-router'
|
||||
|
||||
import { ExplorerHome } from '@/components/interfaces/Explorer/ExplorerHome'
|
||||
import { ExplorerLayout } from '@/components/layouts/ExplorerLayout/ExplorerLayout'
|
||||
|
||||
export const Route = createFileRoute('/project/$ref/explorer')({
|
||||
component: ProjectExplorerRoute,
|
||||
component: ExplorerShell,
|
||||
})
|
||||
|
||||
function ProjectExplorerRoute() {
|
||||
function ExplorerShell() {
|
||||
return (
|
||||
<ExplorerLayout>
|
||||
<ExplorerHome />
|
||||
<Outlet />
|
||||
</ExplorerLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
import { ExplorerHome } from '@/components/interfaces/Explorer/ExplorerHome'
|
||||
|
||||
export const Route = createFileRoute('/project/$ref/explorer/')({
|
||||
component: ProjectExplorerIndexRoute,
|
||||
})
|
||||
|
||||
function ProjectExplorerIndexRoute() {
|
||||
return <ExplorerHome />
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
import NotebookPage from '@/pages/project/[ref]/explorer/notebook/[id]'
|
||||
|
||||
export const Route = createFileRoute('/project/$ref/explorer/notebook/$id')({
|
||||
component: ProjectExplorerNotebookRoute,
|
||||
})
|
||||
|
||||
function ProjectExplorerNotebookRoute() {
|
||||
return <NotebookPage dehydratedState={undefined} />
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Ephemeral, per-session notebook state that is NOT persisted: query cell
|
||||
* results and the row limit. Kept separate from the notebook content store
|
||||
* (which deals with persistence) because none of this is saved — it lives
|
||||
* only for the current editing session, keyed by cell id rather than
|
||||
* notebook id since a single notebook can have many independent query cells.
|
||||
*
|
||||
* [Joshen] Will be fleshed out in subsequent PRs
|
||||
*/
|
||||
@@ -0,0 +1,79 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { notebooksState } from './notebooks-state'
|
||||
import type { Notebook } from './types'
|
||||
|
||||
function makeNotebook(id: string, overrides: Partial<Notebook> = {}): Notebook {
|
||||
return {
|
||||
id,
|
||||
type: 'notebook',
|
||||
name: 'My Notebook',
|
||||
description: '',
|
||||
visibility: 'project',
|
||||
favorite: false,
|
||||
owner_id: 7,
|
||||
project_id: 42,
|
||||
content: { schema_version: '1.0', cells: [] },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('notebooksState', () => {
|
||||
beforeEach(() => {
|
||||
// notebooksState is a module-level singleton, so reset the state these tests touch
|
||||
for (const id of Object.keys(notebooksState.notebooks)) {
|
||||
delete notebooksState.notebooks[id]
|
||||
}
|
||||
notebooksState.needsSaving.clear()
|
||||
})
|
||||
|
||||
it('addNotebook marks a locally-created notebook as new', () => {
|
||||
notebooksState.addNotebook({ projectRef: 'ref', notebook: makeNotebook('notebook-1') })
|
||||
|
||||
expect(notebooksState.notebooks['notebook-1'].status).toBe('new')
|
||||
})
|
||||
|
||||
it('setNotebook marks a notebook not yet in the store as saved', () => {
|
||||
notebooksState.setNotebook({ projectRef: 'ref', notebook: makeNotebook('notebook-1') })
|
||||
|
||||
expect(notebooksState.notebooks['notebook-1'].status).toBe('saved')
|
||||
})
|
||||
|
||||
it('editing a loaded (saved) notebook transitions it to unsaved and queues it for saving', () => {
|
||||
notebooksState.setNotebook({ projectRef: 'ref', notebook: makeNotebook('notebook-1') })
|
||||
|
||||
notebooksState.updateCells({
|
||||
id: 'notebook-1',
|
||||
cells: [{ type: 'markdown', content: 'hello' }],
|
||||
})
|
||||
|
||||
expect(notebooksState.notebooks['notebook-1'].status).toBe('unsaved')
|
||||
expect(notebooksState.needsSaving.get('notebook-1')).toBe(false)
|
||||
})
|
||||
|
||||
it('editing a notebook that has never been saved keeps it as new', () => {
|
||||
notebooksState.addNotebook({ projectRef: 'ref', notebook: makeNotebook('notebook-1') })
|
||||
|
||||
notebooksState.updateCells({
|
||||
id: 'notebook-1',
|
||||
cells: [{ type: 'markdown', content: 'hello' }],
|
||||
})
|
||||
|
||||
expect(notebooksState.notebooks['notebook-1'].status).toBe('new')
|
||||
})
|
||||
|
||||
it('setNotebook does not downgrade an already-loaded notebook back to saved after edits', () => {
|
||||
notebooksState.setNotebook({ projectRef: 'ref', notebook: makeNotebook('notebook-1') })
|
||||
notebooksState.updateCells({
|
||||
id: 'notebook-1',
|
||||
cells: [{ type: 'markdown', content: 'hello' }],
|
||||
})
|
||||
expect(notebooksState.notebooks['notebook-1'].status).toBe('unsaved')
|
||||
|
||||
// Re-fetching/merging content for the same notebook (e.g. a second setNotebook
|
||||
// call) must not reset its status back to 'saved' while edits are pending.
|
||||
notebooksState.setNotebook({ projectRef: 'ref', notebook: makeNotebook('notebook-1') })
|
||||
|
||||
expect(notebooksState.notebooks['notebook-1'].status).toBe('unsaved')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useMemo } from 'react'
|
||||
import { proxy, snapshot, useSnapshot } from 'valtio'
|
||||
import { proxyMap } from 'valtio/utils'
|
||||
|
||||
import type { Notebook, NotebookCell, StateNotebook } from './types'
|
||||
import type { SnippetStatus } from '@/data/content/snippet-status'
|
||||
|
||||
// [Joshen] Deliberately copied from sql-editor-lifecycle cause we might deprecate
|
||||
// that in favor of notebooks in the long run
|
||||
function statusOnEdit(status: SnippetStatus): SnippetStatus {
|
||||
return status === 'saved' ? 'unsaved' : status
|
||||
}
|
||||
|
||||
export const notebooksState = proxy({
|
||||
notebooks: {} as Record<string, StateNotebook>,
|
||||
needsSaving: proxyMap<string, boolean>([]),
|
||||
|
||||
/**
|
||||
* Load notebook into the Valtio store. No-ops if already present.
|
||||
*/
|
||||
addNotebook: ({ projectRef, notebook }: { projectRef: string; notebook: Notebook }) => {
|
||||
if (notebooksState.notebooks[notebook.id]) return
|
||||
notebooksState.notebooks[notebook.id] = { projectRef, notebook, status: 'new' }
|
||||
},
|
||||
|
||||
/**
|
||||
* Load notebook content into the store. Notebooks fetched from the list
|
||||
* endpoint don't have `content` loaded (to keep that response small), so
|
||||
* content is fetched separately and merged in here on demand.
|
||||
*
|
||||
* Unlike `addNotebook` (for locally-created notebooks, status 'new'), a
|
||||
* notebook reaching this function was already persisted, so it's inserted
|
||||
* with status 'saved'.
|
||||
*/
|
||||
setNotebook: ({ projectRef, notebook }: { projectRef: string; notebook: Notebook }) => {
|
||||
const stateNotebook = notebooksState.notebooks[notebook.id]
|
||||
if (stateNotebook) {
|
||||
if (!stateNotebook.notebook.content) {
|
||||
stateNotebook.notebook.content = notebook.content
|
||||
}
|
||||
} else {
|
||||
notebooksState.notebooks[notebook.id] = { projectRef, notebook, status: 'saved' }
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Rename follows its own async save directly at the call site rather than going
|
||||
* through needsSaving/the debounced scheduler.
|
||||
*/
|
||||
renameNotebook: ({ id, name }: { id: string; name: string }) => {
|
||||
const stateNotebook = notebooksState.notebooks[id]
|
||||
if (stateNotebook) {
|
||||
stateNotebook.notebook.name = name
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove notebook from the store, and optionally remove it from the sync
|
||||
* saving queue. Also clears any cached query-cell results for this notebook
|
||||
* from the ephemeral session store.
|
||||
*/
|
||||
removeNotebook: ({ id, skipSave = false }: { id: string; skipSave?: boolean }) => {
|
||||
const { [id]: notebook, ...otherNotebooks } = notebooksState.notebooks
|
||||
notebooksState.notebooks = otherNotebooks
|
||||
if (!skipSave) notebooksState.needsSaving.delete(id)
|
||||
|
||||
// TODO: clear notebookSessionState once it exists
|
||||
},
|
||||
|
||||
/**
|
||||
* Replace a notebook's full cell array and queue it for sync saving. The
|
||||
* single entry point for every cell-level change — adding, removing,
|
||||
* reordering, or editing a cell's content all compute the next `cells` array
|
||||
* at the call site and pass it here, since the notebook is saved as one JSON
|
||||
* document rather than per-cell.
|
||||
*/
|
||||
updateCells: ({
|
||||
id,
|
||||
cells,
|
||||
skipSave,
|
||||
}: {
|
||||
id: string
|
||||
cells: NotebookCell[]
|
||||
skipSave?: boolean
|
||||
}) => {
|
||||
const stateNotebook = notebooksState.notebooks[id]
|
||||
if (!stateNotebook?.notebook.content) return
|
||||
stateNotebook.notebook.content.cells = cells
|
||||
stateNotebook.status = statusOnEdit(stateNotebook.status)
|
||||
if (!skipSave) notebooksState.needsSaving.set(id, false)
|
||||
},
|
||||
|
||||
addNeedsSaving: (id: string) => notebooksState.needsSaving.set(id, true),
|
||||
})
|
||||
|
||||
export const getNotebooksStateSnapshot = () => snapshot(notebooksState)
|
||||
|
||||
export const useNotebooksStateSnapshot = (options?: Parameters<typeof useSnapshot>[1]) =>
|
||||
useSnapshot(notebooksState, options)
|
||||
|
||||
export const useNotebooks = (projectRef: string) => {
|
||||
const snapshot = useNotebooksStateSnapshot()
|
||||
return useMemo(
|
||||
() =>
|
||||
Object.values(snapshot.notebooks)
|
||||
.filter((x) => x.projectRef === projectRef)
|
||||
.map((x) => x.notebook),
|
||||
[projectRef, snapshot.notebooks]
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { SnippetStatus } from '@/data/content/snippet-status'
|
||||
|
||||
/** Start and end follows ISO8601 convention */
|
||||
type AbsoluteTimeRange = { start: string; end: string }
|
||||
type RelativeTimeRange = { unit: 'm' | 'h' | 'd' | 'w' | 'M' | 'y'; amount: number }
|
||||
type TimeRange = AbsoluteTimeRange | RelativeTimeRange
|
||||
|
||||
type DatabaseQueryCell = { type: 'sql'; sql: string }
|
||||
type LogsQueryCell = { type: 'logs'; sql: string; range: TimeRange }
|
||||
type MarkdownCell = { type: 'markdown'; content: string }
|
||||
export type NotebookCell = DatabaseQueryCell | LogsQueryCell | MarkdownCell
|
||||
|
||||
interface NotebookContent {
|
||||
schema_version: string
|
||||
cells: NotebookCell[]
|
||||
}
|
||||
|
||||
export interface Notebook {
|
||||
id: string
|
||||
type: 'notebook'
|
||||
name: string
|
||||
description?: string
|
||||
visibility: 'project'
|
||||
favorite: boolean
|
||||
owner_id: number
|
||||
project_id: number
|
||||
content?: NotebookContent // Undefined until loaded
|
||||
}
|
||||
|
||||
export interface StateNotebook {
|
||||
projectRef: string
|
||||
notebook: Notebook
|
||||
status: SnippetStatus
|
||||
}
|
||||
@@ -22,7 +22,7 @@ export const editorEntityTypes = {
|
||||
explorer: ['notebook'],
|
||||
}
|
||||
|
||||
export type TabType = ENTITY_TYPE | 'sql'
|
||||
export type TabType = ENTITY_TYPE | 'sql' | 'notebook'
|
||||
|
||||
type CreateTabIdParams = {
|
||||
r: { id: number }
|
||||
@@ -31,6 +31,7 @@ type CreateTabIdParams = {
|
||||
f: { id: number }
|
||||
p: { id: number }
|
||||
sql: { id: string }
|
||||
notebook: { id: string }
|
||||
schema: { schema: string }
|
||||
view: never
|
||||
function: never
|
||||
@@ -46,6 +47,7 @@ export interface Tab {
|
||||
name?: string
|
||||
tableId?: number
|
||||
sqlId?: string
|
||||
notebookId?: string
|
||||
scrollTop?: number
|
||||
/**
|
||||
* For SQL tabs, which backend the snippet queries (`'database'` | `'logs'`),
|
||||
@@ -519,6 +521,9 @@ export function createTabsState(projectRef: string) {
|
||||
case 'sql':
|
||||
router.push(`/project/${router.query.ref}/sql`)
|
||||
break
|
||||
case 'notebook':
|
||||
router.push(`/project/${router.query.ref}/explorer`)
|
||||
break
|
||||
case 'r':
|
||||
case 'v':
|
||||
case 'm':
|
||||
@@ -638,6 +643,8 @@ export function createTabId<T extends TabType>(type: T, params: CreateTabIdParam
|
||||
return `p-${(params as CreateTabIdParams['p']).id}`
|
||||
case 'sql':
|
||||
return `sql-${(params as CreateTabIdParams['sql']).id}`
|
||||
case 'notebook':
|
||||
return `notebook-${(params as CreateTabIdParams['sql']).id}`
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user