mirror of
https://github.com/supabase/supabase.git
synced 2026-10-10 11:55:05 +03:00
tables
This commit is contained in:
1 parent
84d089f1e7
commit
f90471ba40
19 files changed
+833
-92
No files matched your search
@@ -94,8 +94,14 @@ export const ExplorerQueryTab = () => {
|
||||
useEffect(() => {
|
||||
if (!id || !ref) return
|
||||
|
||||
setShowQuery(true)
|
||||
explorerQueryState.restoreDraft({ id, projectRef: ref })
|
||||
|
||||
// A table is opened for its rows, so it lands on the results with the query tucked away;
|
||||
// an ad-hoc query opens on the editor the user came to write in.
|
||||
const restored = explorerQueryState.drafts[id]
|
||||
const isEntityQuery = restored?._tag === 'database' && restored.entity !== undefined
|
||||
setShowQuery(!isEntityQuery)
|
||||
|
||||
setRestoredQueryKey(`${ref}:${id}`)
|
||||
}, [id, ref])
|
||||
|
||||
@@ -138,6 +144,7 @@ export const ExplorerQueryTab = () => {
|
||||
...toQuerySourceBinding(draft),
|
||||
uncheckedSql: draft.uncheckedSql,
|
||||
rowLimit: draft.rowLimit,
|
||||
entity: draft.entity,
|
||||
}
|
||||
|
||||
const persistTab = () => tabs.makeTabPermanent(createTabId('query', { id }))
|
||||
@@ -183,6 +190,9 @@ export const ExplorerQueryTab = () => {
|
||||
title={draft.name}
|
||||
query={query}
|
||||
result={result}
|
||||
// A table is opened to see its rows, so it runs itself. The editor honors this only
|
||||
// while the query is still the generated one — an edited query waits for Run.
|
||||
autoRun
|
||||
display={display}
|
||||
showQuery={showQuery}
|
||||
onShowQueryChange={setShowQuery}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useParams } from 'common'
|
||||
import { useContext, useEffect } from 'react'
|
||||
|
||||
import { explorerQueryState } from '@/state/explorer-query'
|
||||
import { explorerQueryState, hasDiscardableContent } from '@/state/explorer-query'
|
||||
import { TabsStateContext } from '@/state/tabs'
|
||||
|
||||
/**
|
||||
@@ -23,7 +23,8 @@ export const ExplorerQueryTabCoordinator = () => {
|
||||
const populatedDraftCount = queryTabs.filter((tab) => {
|
||||
const queryId = tab.metadata?.queryId
|
||||
if (!queryId) return false
|
||||
return explorerQueryState.drafts[queryId]?.uncheckedSql.trim().length > 0
|
||||
const draft = explorerQueryState.drafts[queryId]
|
||||
return draft !== undefined && hasDiscardableContent(draft)
|
||||
}).length
|
||||
|
||||
if (populatedDraftCount === 0) return null
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { useMonaco } from '@monaco-editor/react'
|
||||
import { acceptUntrustedSql, untrustedSql, type UntrustedSqlFragment } from '@supabase/pg-meta'
|
||||
import {
|
||||
acceptUntrustedSql,
|
||||
untrustedSql,
|
||||
type SafeSqlFragment,
|
||||
type UntrustedSqlFragment,
|
||||
} from '@supabase/pg-meta'
|
||||
import { useFlag } from 'common'
|
||||
import { CodeSquare, Eye, EyeOff } from 'lucide-react'
|
||||
import type { editor as monacoEditor, Selection } from 'monaco-editor'
|
||||
@@ -15,6 +20,7 @@ import {
|
||||
import { Button, cn } from 'ui'
|
||||
|
||||
import { resolveLogTimeRange } from '../../QuerySources/LogTimeRange.utils'
|
||||
import { buildEntitySelectSql, type QueryEntityBinding } from '../entityQuery.utils'
|
||||
import {
|
||||
ExplorerQuery,
|
||||
ExplorerQueryEditor,
|
||||
@@ -99,6 +105,8 @@ export type ExplorerQueryModel =
|
||||
_tag: 'database'
|
||||
uncheckedSql: UntrustedSqlFragment
|
||||
rowLimit?: number
|
||||
/** Set when this query was opened from a database object rather than typed. */
|
||||
entity?: QueryEntityBinding
|
||||
} & DatabaseSourceParameters)
|
||||
| ({
|
||||
_tag: 'logs'
|
||||
@@ -133,6 +141,15 @@ type QueryEditorProps = {
|
||||
onShowQueryChange: (showQuery: boolean) => void
|
||||
/** When true, toolbar and editor run actions are disabled. */
|
||||
isRunDisabled?: boolean
|
||||
/**
|
||||
* Runs the query once on open, without waiting for the user to hit Run — for a surface
|
||||
* whose whole point is the rows, such as a table opened from the sidebar.
|
||||
*
|
||||
* Honored only while the query still holds exactly the SQL its `entity` generates. An
|
||||
* automatic run has no gesture behind it to approve the editor's untrusted text, so the
|
||||
* SQL that runs is rebuilt from the binding instead of promoted from the buffer.
|
||||
*/
|
||||
autoRun?: boolean
|
||||
onTitleChange: (title: string) => void
|
||||
onSqlChange: (sql: string) => void
|
||||
onSqlCommit?: (sql: string) => void
|
||||
@@ -163,6 +180,7 @@ export const QueryEditor = forwardRef<QueryEditorHandle, QueryEditorProps>(funct
|
||||
showQuery,
|
||||
onShowQueryChange,
|
||||
isRunDisabled = false,
|
||||
autoRun = false,
|
||||
onTitleChange,
|
||||
onSqlChange,
|
||||
onSqlCommit,
|
||||
@@ -240,6 +258,41 @@ export const QueryEditor = forwardRef<QueryEditorHandle, QueryEditorProps>(funct
|
||||
const isExecuting = isExecutingSql || isExecutingLogs
|
||||
const isBusy = isLoadingProject || isResolvingDatabase || isExecuting
|
||||
|
||||
/**
|
||||
* Runs SQL that has already cleared the untrusted boundary — promoted by a run gesture in
|
||||
* `handleRunQuery`, or generated by Studio itself for an automatic run. Splitting it out is
|
||||
* what lets the automatic path execute without ever promoting the editor's buffer.
|
||||
*/
|
||||
const executeDatabaseSql = async (
|
||||
safeSql: SafeSqlFragment,
|
||||
querySnapshot: { sql: string; source: 'database' | 'logs' }
|
||||
) => {
|
||||
if (!project) return
|
||||
|
||||
const limitedSql = applyAutoLimit(safeSql, rowLimit)
|
||||
|
||||
if (!isValidConnString(connectionString)) {
|
||||
onResultChange({
|
||||
error: { message: 'Unable to run query: Connection string is missing' },
|
||||
...querySnapshot,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await executeSql({
|
||||
projectRef: project.ref,
|
||||
connectionString,
|
||||
sql: wrapWithRoleImpersonation(limitedSql.sql, roleImpersonationState),
|
||||
autoLimit: limitedSql.appendAutoLimit ? rowLimit : undefined,
|
||||
contextualInvalidation: true,
|
||||
isStatementTimeoutDisabled: true,
|
||||
isRoleImpersonationEnabled: isRoleImpersonationEnabled(roleImpersonationState?.role),
|
||||
}).then(
|
||||
(data) => onResultChange({ rows: data.result, ...querySnapshot }),
|
||||
(error) => onResultChange({ error, ...querySnapshot })
|
||||
)
|
||||
}
|
||||
|
||||
const handleRunQuery = async ({
|
||||
rawSql = sql,
|
||||
shouldForce = false,
|
||||
@@ -288,31 +341,28 @@ export const QueryEditor = forwardRef<QueryEditorHandle, QueryEditorProps>(funct
|
||||
return
|
||||
}
|
||||
|
||||
const safeSql = acceptUntrustedSql(untrustedSql(rawSql))
|
||||
const limitedSql = applyAutoLimit(safeSql, rowLimit)
|
||||
|
||||
if (!isValidConnString(connectionString)) {
|
||||
onResultChange({
|
||||
error: { message: 'Unable to run query: Connection string is missing' },
|
||||
...querySnapshot,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await executeSql({
|
||||
projectRef: project.ref,
|
||||
connectionString,
|
||||
sql: wrapWithRoleImpersonation(limitedSql.sql, roleImpersonationState),
|
||||
autoLimit: limitedSql.appendAutoLimit ? rowLimit : undefined,
|
||||
contextualInvalidation: true,
|
||||
isStatementTimeoutDisabled: true,
|
||||
isRoleImpersonationEnabled: isRoleImpersonationEnabled(roleImpersonationState?.role),
|
||||
}).then(
|
||||
(data) => onResultChange({ rows: data.result, ...querySnapshot }),
|
||||
(error) => onResultChange({ error, ...querySnapshot })
|
||||
)
|
||||
await executeDatabaseSql(acceptUntrustedSql(untrustedSql(rawSql)), querySnapshot)
|
||||
}
|
||||
|
||||
/**
|
||||
* The SQL an entity-backed query generates, rebuilt from the binding rather than read from
|
||||
* the editor. `autoRun` is honored only while the two still match: once the user edits the
|
||||
* query it is their own untrusted text, which may not run without a gesture.
|
||||
*/
|
||||
const generatedSql =
|
||||
query._tag === 'database' && query.entity ? buildEntitySelectSql(query.entity) : undefined
|
||||
// Compared trimmed, which also drops the branding both sides carry — the point is whether
|
||||
// the text on screen is still the text Studio generated.
|
||||
const canAutoRun = autoRun && generatedSql !== undefined && generatedSql.trim() === sql.trim()
|
||||
|
||||
const runGeneratedQueryRef = useLatest(() => {
|
||||
if (generatedSql === undefined) return
|
||||
// No blocking-issue check: `generatedSql` is a plain select Studio built itself, and a
|
||||
// confirmation modal has no meaning on a run the user did not ask for.
|
||||
onRun?.()
|
||||
executeDatabaseSql(generatedSql, { sql: generatedSql, source: 'database' })
|
||||
})
|
||||
|
||||
const handleConfirmPendingRun = () => {
|
||||
if (!pendingRun) return
|
||||
const runSql = pendingRun.sql
|
||||
@@ -381,6 +431,20 @@ export const QueryEditor = forwardRef<QueryEditorHandle, QueryEditorProps>(funct
|
||||
prettify: handlePrettify,
|
||||
}))
|
||||
|
||||
// Runs once per query, and only once the project has resolved — the same conditions that
|
||||
// enable the Run button. Skipped when a result is already in hand, so returning to an open
|
||||
// tab shows what it had rather than re-querying.
|
||||
const autoRunKeyRef = useRef<string>(undefined)
|
||||
const isReadyToAutoRun =
|
||||
canAutoRun && project !== undefined && result === undefined && !isBusy && !isRunDisabled
|
||||
|
||||
useEffect(() => {
|
||||
if (!isReadyToAutoRun || autoRunKeyRef.current === id) return
|
||||
|
||||
autoRunKeyRef.current = id
|
||||
runGeneratedQueryRef.current()
|
||||
}, [isReadyToAutoRun, id, runGeneratedQueryRef])
|
||||
|
||||
useEffect(() => {
|
||||
if (!promptState?.isOpen) return
|
||||
const node = editorInstanceRef.current?.getDomNode()
|
||||
@@ -431,6 +495,7 @@ export const QueryEditor = forwardRef<QueryEditorHandle, QueryEditorProps>(funct
|
||||
icon={
|
||||
showQuery ? <EyeOff size={16} strokeWidth={2} /> : <Eye size={16} strokeWidth={2} />
|
||||
}
|
||||
aria-label={showQuery ? 'Hide query' : 'Show query'}
|
||||
disabled={pendingProposal !== null}
|
||||
tooltip={showQuery ? 'Hide query' : 'Show query'}
|
||||
onClick={() => onShowQueryChange(!showQuery)}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useEffect, useRef } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { ExplorerQueryTab } from '../ExplorerQueryTab'
|
||||
import { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants'
|
||||
import type { ReadReplicasData } from '@/data/read-replicas/replicas-query'
|
||||
import { explorerQueryState } from '@/state/explorer-query'
|
||||
import { createTabId, createTabsState, TabsStateContext } from '@/state/tabs'
|
||||
@@ -166,6 +167,39 @@ const createDraft = (
|
||||
})
|
||||
}
|
||||
|
||||
const TABLE_ENTITY = { schema: 'public', name: 'users', type: ENTITY_TYPE.TABLE } as const
|
||||
|
||||
const createEntityDraft = (sql: string = 'select * from public.users') => {
|
||||
explorerQueryState.removeDraft({ id: 'query-test', projectRef: 'default' })
|
||||
explorerQueryState.createDraft({
|
||||
id: 'query-test',
|
||||
projectRef: 'default',
|
||||
sql,
|
||||
source: { _tag: 'database' },
|
||||
entity: TABLE_ENTITY,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Intellisense, event triggers and other background fetches share the pg-meta query endpoint
|
||||
* with executions, each tagged by a `key` search param. A run carries no key.
|
||||
*/
|
||||
const captureQueryExecutions = () => {
|
||||
const bodies: Array<{ query: string }> = []
|
||||
|
||||
addAPIMock({
|
||||
method: 'post',
|
||||
path: '/platform/pg-meta/:ref/query',
|
||||
response: async ({ request }) => {
|
||||
const key = new URL(request.url).searchParams.get('key')
|
||||
if (!key) bodies.push((await request.json()) as { query: string })
|
||||
return HttpResponse.json([])
|
||||
},
|
||||
})
|
||||
|
||||
return bodies
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setupSqlEditorMocks()
|
||||
testContext.flags.otelLegacyLogs = true
|
||||
@@ -536,3 +570,52 @@ describe('QueryTab execution', () => {
|
||||
expect(executedQueries[0]).toContain('select 2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('QueryTab auto-run', () => {
|
||||
it('runs a table query on open without waiting for the user', async () => {
|
||||
createEntityDraft()
|
||||
const executions = captureQueryExecutions()
|
||||
|
||||
renderQueryTab()
|
||||
|
||||
await waitFor(() => expect(executions).toHaveLength(1))
|
||||
expect(executions[0]?.query).toContain('select * from public.users')
|
||||
})
|
||||
|
||||
it('runs only once, even as the editor re-renders', async () => {
|
||||
createEntityDraft()
|
||||
const executions = captureQueryExecutions()
|
||||
|
||||
renderQueryTab()
|
||||
|
||||
await waitFor(() => expect(executions).toHaveLength(1))
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Show query' }))
|
||||
|
||||
expect(executions).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('waits for Run once the query is no longer the one it generated', async () => {
|
||||
createEntityDraft('select id from public.users')
|
||||
const executions = captureQueryExecutions()
|
||||
|
||||
renderQueryTab()
|
||||
|
||||
const runButton = await screen.findByRole('button', { name: 'Run' })
|
||||
await waitFor(() => expect(runButton).toBeEnabled())
|
||||
expect(executions).toHaveLength(0)
|
||||
|
||||
await userEvent.click(runButton)
|
||||
await waitFor(() => expect(executions).toHaveLength(1))
|
||||
})
|
||||
|
||||
it('leaves an ad-hoc query alone', async () => {
|
||||
createDraft({ _tag: 'database' }, 'select 1')
|
||||
const executions = captureQueryExecutions()
|
||||
|
||||
renderQueryTab()
|
||||
|
||||
const runButton = await screen.findByRole('button', { name: 'Run' })
|
||||
await waitFor(() => expect(runButton).toBeEnabled())
|
||||
expect(executions).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { buildEntitySelectSql, entityQueryId } from './entityQuery.utils'
|
||||
|
||||
describe('entityQueryId', () => {
|
||||
it('is stable for the same entity, so reopening a table returns to the same draft', () => {
|
||||
expect(entityQueryId({ schema: 'public', name: 'users' })).toBe(
|
||||
entityQueryId({ schema: 'public', name: 'users' })
|
||||
)
|
||||
})
|
||||
|
||||
it('separates schema from name, so identifiers containing the separator cannot collide', () => {
|
||||
expect(entityQueryId({ schema: 'a:b', name: 'c' })).not.toBe(
|
||||
entityQueryId({ schema: 'a', name: 'b:c' })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildEntitySelectSql', () => {
|
||||
it('reads the relation by its qualified name', () => {
|
||||
expect(buildEntitySelectSql({ schema: 'public', name: 'users' })).toBe(
|
||||
'select * from public.users'
|
||||
)
|
||||
})
|
||||
|
||||
it('escapes identifiers that would otherwise break out of the quoting', () => {
|
||||
expect(buildEntitySelectSql({ schema: 'public', name: 'we"ird' })).toBe(
|
||||
'select * from public."we""ird"'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
import { ident, safeSql } from '@supabase/pg-meta'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants'
|
||||
|
||||
/** Entity types the sidebar's Tables level lists, and that open as a `select *` query. */
|
||||
export const TABLE_ENTITY_TYPES: ENTITY_TYPE[] = [ENTITY_TYPE.TABLE, ENTITY_TYPE.PARTITIONED_TABLE]
|
||||
|
||||
/**
|
||||
* The database entity a query was opened from. A query opened this way is an ordinary
|
||||
* editable query — the binding records only where it came from, which is what lets its tab
|
||||
* show the entity's icon, lets closing it skip the discard prompt, and is where in-place
|
||||
* editing of the results will hang off once the query editor supports it.
|
||||
*/
|
||||
export const queryEntityBindingSchema = z.object({
|
||||
schema: z.string(),
|
||||
name: z.string(),
|
||||
type: z.nativeEnum(ENTITY_TYPE),
|
||||
})
|
||||
|
||||
export type QueryEntityBinding = z.infer<typeof queryEntityBindingSchema>
|
||||
|
||||
/**
|
||||
* Deterministic draft id for an entity, so clicking the same table twice returns to the one
|
||||
* tab — and to whatever the user had edited in it — rather than stacking up new drafts.
|
||||
* Both parts are percent-encoded, which escapes the separator, so no pair of identifiers
|
||||
* can produce the same id. The `entity:` prefix keeps these out of the uuid namespace that
|
||||
* ad-hoc queries use.
|
||||
*/
|
||||
export const entityQueryId = ({ schema, name }: Pick<QueryEntityBinding, 'schema' | 'name'>) =>
|
||||
`entity:${encodeURIComponent(schema)}:${encodeURIComponent(name)}`
|
||||
|
||||
/** The query an entity opens with. Regenerated rather than stored, so it stays reproducible. */
|
||||
export const buildEntitySelectSql = ({
|
||||
schema,
|
||||
name,
|
||||
}: Pick<QueryEntityBinding, 'schema' | 'name'>) =>
|
||||
safeSql`select * from ${ident(schema)}.${ident(name)}`
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect, useEffectEvent, useState } from 'react'
|
||||
|
||||
import { buildEntitySelectSql, entityQueryId, type QueryEntityBinding } from './entityQuery.utils'
|
||||
import { useNotebookQuery } from '@/data/content/notebooks/notebook-query'
|
||||
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
@@ -8,7 +9,7 @@ import { generateUuid } from '@/lib/api/snippets.browser'
|
||||
import { useProfile } from '@/lib/profile'
|
||||
import type { AssistantModel } from '@/state/ai-assistant-state'
|
||||
import { useAiAssistantState, whenAiAssistantInitialized } from '@/state/ai-assistant-state'
|
||||
import { useExplorerQueryStateSnapshot } from '@/state/explorer-query'
|
||||
import { explorerQueryState, useExplorerQueryStateSnapshot } from '@/state/explorer-query'
|
||||
import { useNotebooksStateSnapshot } from '@/state/notebooks/notebooks-state'
|
||||
import { type Notebook } from '@/state/notebooks/types'
|
||||
import { Notebooks } from '@/types'
|
||||
@@ -161,3 +162,38 @@ export const useCreateQuery = () => {
|
||||
|
||||
return { createQuery }
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a database entity as an ordinary Explorer query — a `select *` draft keyed by the
|
||||
* entity, landing in the same tab, editor and result surface as any query the user types
|
||||
* themselves. An entity is not a separate kind of thing to open; it is a query with a known
|
||||
* origin, which is all the extra the draft records.
|
||||
*/
|
||||
export const useOpenEntityQuery = () => {
|
||||
const router = useRouter()
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
|
||||
const openEntityQuery = (entity: QueryEntityBinding) => {
|
||||
if (!project) return console.error('Project is required')
|
||||
|
||||
const id = entityQueryId(entity)
|
||||
|
||||
// Reuses the draft when one already exists, so reopening a table returns the user to
|
||||
// whatever they had edited there rather than resetting it.
|
||||
if (!explorerQueryState.restoreDraft({ id, projectRef: project.ref })) {
|
||||
explorerQueryState.createDraft({
|
||||
id,
|
||||
projectRef: project.ref,
|
||||
name: `${entity.schema}.${entity.name}`,
|
||||
sql: buildEntitySelectSql(entity),
|
||||
entity,
|
||||
})
|
||||
}
|
||||
|
||||
router.push(`/project/${project.ref}/explorer/query/${id}`)
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
return { openEntityQuery }
|
||||
}
|
||||
@@ -1,32 +1,46 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import { ChevronLeft, MessageSquare, NotebookText, Plus } from 'lucide-react'
|
||||
import { type ComponentType, type PropsWithChildren } from 'react'
|
||||
import { ChevronLeft, Database, MessageSquare, NotebookText, Plus } from 'lucide-react'
|
||||
import { type ComponentType, type PropsWithChildren, type ReactNode } from 'react'
|
||||
import { Button, cn } from 'ui'
|
||||
import { InnerSideBarFilters, InnerSideBarFilterSearchInput } from 'ui-patterns/InnerSideMenu'
|
||||
|
||||
import { useCreateChat, useCreateNotebook } from '@/components/interfaces/Explorer/hooks'
|
||||
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
|
||||
|
||||
/** Explorer resources the sidebar can both list and create. */
|
||||
export type ExplorerResourceType = 'notebook' | 'chat'
|
||||
|
||||
/**
|
||||
* A level in the sidebar's drill-down stack. The layout keeps these on a stack, so going
|
||||
* back is a pop and a new level costs one member here plus the panel that renders it.
|
||||
*/
|
||||
export type ExplorerNavLevel = ExplorerResourceType | 'database' | 'database-tables'
|
||||
|
||||
type ExplorerNavIcon = ComponentType<{ size?: number; className?: string }>
|
||||
|
||||
/** Icon and filter copy per resource, shared by its panel and the recently updated list. */
|
||||
export const EXPLORER_RESOURCES: Record<
|
||||
ExplorerResourceType,
|
||||
{ icon: ExplorerNavIcon; searchPlaceholder: string }
|
||||
> = {
|
||||
notebook: { icon: NotebookText, searchPlaceholder: 'Search notebooks' },
|
||||
chat: { icon: MessageSquare, searchPlaceholder: 'Search chats' },
|
||||
}
|
||||
|
||||
/** Top-level sidebar destinations, in display order. */
|
||||
export const EXPLORER_SECTIONS: Array<{
|
||||
level: ExplorerNavLevel
|
||||
label: string
|
||||
icon: ExplorerNavIcon
|
||||
}> = [
|
||||
{ level: 'database', label: 'Database', icon: Database },
|
||||
{ level: 'notebook', label: 'Notebooks', icon: EXPLORER_RESOURCES.notebook.icon },
|
||||
{ level: 'chat', label: 'Chats', icon: EXPLORER_RESOURCES.chat.icon },
|
||||
]
|
||||
|
||||
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',
|
||||
@@ -35,26 +49,30 @@ export const rowClassName = (isActive: boolean) =>
|
||||
: 'text-foreground-light hover:bg-surface-200 hover:text-foreground'
|
||||
)
|
||||
|
||||
export const ExplorerNavResourceWrapper = ({
|
||||
type,
|
||||
/**
|
||||
* One level of the sidebar: the sliding panel, its back control, an optional filter input,
|
||||
* and an optional action. Levels differ only in what they list, so they compose this rather
|
||||
* than each rebuilding the header.
|
||||
*/
|
||||
export const ExplorerNavPanel = ({
|
||||
label,
|
||||
className,
|
||||
children,
|
||||
search,
|
||||
setSearch,
|
||||
searchPlaceholder,
|
||||
action,
|
||||
onBack,
|
||||
}: PropsWithChildren<{
|
||||
type: ExplorerResourceType
|
||||
label?: string
|
||||
label: string
|
||||
className?: string
|
||||
search?: string
|
||||
setSearch: (value: string) => void
|
||||
/** Renders the filter input when given; otherwise the header shows `label` as a title. */
|
||||
setSearch?: (value: string) => void
|
||||
searchPlaceholder?: string
|
||||
action?: ReactNode
|
||||
onBack: () => void
|
||||
}>) => {
|
||||
const { createNotebook } = useCreateNotebook()
|
||||
const { createChat } = useCreateChat()
|
||||
const searchPlaceholder = EXPLORER_SECTIONS.find((x) => x.type === type)?.searchPlaceholder
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
role="group"
|
||||
@@ -74,18 +92,61 @@ export const ExplorerNavResourceWrapper = ({
|
||||
className="size-7 shrink-0 px-0"
|
||||
icon={<ChevronLeft />}
|
||||
/>
|
||||
<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>
|
||||
{setSearch === undefined ? (
|
||||
<span className="flex-1 truncate text-sm text-foreground">{label}</span>
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
{action}
|
||||
</div>
|
||||
{children}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
/** An `ExplorerNavPanel` for a creatable resource, adding its "new" action to the header. */
|
||||
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 { createNotebook } = useCreateNotebook()
|
||||
const { createChat } = useCreateChat()
|
||||
const { searchPlaceholder } = EXPLORER_RESOURCES[type]
|
||||
|
||||
return (
|
||||
<ExplorerNavPanel
|
||||
label={label ?? EXPLORER_SECTIONS.find((section) => section.level === type)?.label ?? type}
|
||||
className={className}
|
||||
search={search}
|
||||
setSearch={setSearch}
|
||||
searchPlaceholder={searchPlaceholder}
|
||||
onBack={onBack}
|
||||
action={
|
||||
<ButtonTooltip
|
||||
size="tiny"
|
||||
variant="outline"
|
||||
@@ -98,8 +159,9 @@ export const ExplorerNavResourceWrapper = ({
|
||||
if (type === 'chat') createChat()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</ExplorerNavPanel>
|
||||
)
|
||||
}
|
||||
@@ -15,10 +15,12 @@ import {
|
||||
import { EditorNavigationButton } from '../EditorNavigationButton'
|
||||
import { ProjectLayoutWithAuth } from '../ProjectLayout'
|
||||
import { EditorTabs } from '../Tabs/Tabs'
|
||||
import { type ExplorerResourceType } from './ExplorerLayout.constants'
|
||||
import { type ExplorerNavLevel } from './ExplorerLayout.constants'
|
||||
import { ExplorerNavChats } from './ExplorerNavChats'
|
||||
import { ExplorerNavDatabase } from './ExplorerNavDatabase'
|
||||
import { ExplorerNavHome } from './ExplorerNavHome'
|
||||
import { ExplorerNavNotebooks } from './ExplorerNavNotebooks'
|
||||
import { ExplorerNavTables } from './ExplorerNavTables'
|
||||
import { ExplorerGeneratedPageTabCoordinator } from '@/components/interfaces/Explorer/ExplorerGeneratedPageTabCoordinator'
|
||||
import { ExplorerNotebookTabCoordinator } from '@/components/interfaces/Explorer/ExplorerNotebookTabCoordinator'
|
||||
import { ExplorerQueryTabCoordinator } from '@/components/interfaces/Explorer/ExplorerQueryTabCoordinator'
|
||||
@@ -46,7 +48,13 @@ export const ExplorerLayout = ({ browserTitle, children, title }: ExplorerLayout
|
||||
const { ref } = useParams()
|
||||
const tabs = useTabsStateSnapshot()
|
||||
|
||||
const [section, setSection] = useState<ExplorerResourceType>()
|
||||
// A stack rather than a single value, so drilling in is a push and going back is a pop —
|
||||
// which is what lets Database sit two levels deep without the layout tracking parents.
|
||||
const [navStack, setNavStack] = useState<ExplorerNavLevel[]>([])
|
||||
const level = navStack.at(-1)
|
||||
|
||||
const pushLevel = (next: ExplorerNavLevel) => setNavStack((stack) => [...stack, next])
|
||||
const popLevel = () => setNavStack((stack) => stack.slice(0, -1))
|
||||
|
||||
const { setIsTemporary: setIsTemporarySqlEditorVisit } = useIsTemporarySqlEditorVisit(ref)
|
||||
|
||||
@@ -73,13 +81,15 @@ export const ExplorerLayout = ({ browserTitle, children, title }: ExplorerLayout
|
||||
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)} />
|
||||
{level === undefined && <ExplorerNavHome key="home" onSelectLevel={pushLevel} />}
|
||||
{level === 'database' && (
|
||||
<ExplorerNavDatabase key="database" onBack={popLevel} onSelectLevel={pushLevel} />
|
||||
)}
|
||||
{section === 'chat' && (
|
||||
<ExplorerNavChats key="chats" onBack={() => setSection(undefined)} />
|
||||
{level === 'database-tables' && (
|
||||
<ExplorerNavTables key="database-tables" onBack={popLevel} />
|
||||
)}
|
||||
{level === 'notebook' && <ExplorerNavNotebooks key="notebooks" onBack={popLevel} />}
|
||||
{level === 'chat' && <ExplorerNavChats key="chats" onBack={popLevel} />}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { ChevronRight, Table2 } from 'lucide-react'
|
||||
import { type ComponentType } from 'react'
|
||||
|
||||
import { ExplorerNavPanel, rowClassName, type ExplorerNavLevel } from './ExplorerLayout.constants'
|
||||
|
||||
/**
|
||||
* Database object kinds, in sidebar order. Tables ship first; functions, triggers and the
|
||||
* rest each add a row here plus the panel their level drills into.
|
||||
*/
|
||||
const DATABASE_SECTIONS: Array<{
|
||||
level: ExplorerNavLevel
|
||||
label: string
|
||||
icon: ComponentType<{ size?: number; className?: string }>
|
||||
}> = [{ level: 'database-tables', label: 'Tables', icon: Table2 }]
|
||||
|
||||
export const ExplorerNavDatabase = ({
|
||||
onBack,
|
||||
onSelectLevel,
|
||||
}: {
|
||||
onBack: () => void
|
||||
onSelectLevel: (level: ExplorerNavLevel) => void
|
||||
}) => {
|
||||
return (
|
||||
<ExplorerNavPanel label="Database" onBack={onBack}>
|
||||
<nav className="flex flex-col gap-px px-3 pb-3">
|
||||
{DATABASE_SECTIONS.map(({ level, label, icon: Icon }) => (
|
||||
<button
|
||||
key={level}
|
||||
type="button"
|
||||
tabIndex={0}
|
||||
className={rowClassName(false)}
|
||||
onClick={() => onSelectLevel(level)}
|
||||
>
|
||||
<Icon size={14} className="shrink-0" />
|
||||
<span className="flex-1 text-left">{label}</span>
|
||||
<ChevronRight size={14} className="shrink-0 text-foreground-muted" />
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</ExplorerNavPanel>
|
||||
)
|
||||
}
|
||||
@@ -5,11 +5,12 @@ import Link from 'next/link'
|
||||
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
import {
|
||||
EXPLORER_RESOURCES,
|
||||
EXPLORER_SECTIONS,
|
||||
ExplorerResourceType,
|
||||
LEVEL_OFFSET,
|
||||
LEVEL_TRANSITION,
|
||||
rowClassName,
|
||||
type ExplorerNavLevel,
|
||||
} from './ExplorerLayout.constants'
|
||||
import { formatRelativeTimeShort, getRecentlyUpdatedItems } from './ExplorerNavHome.utils'
|
||||
import { useCreateChat } from '@/components/interfaces/Explorer/hooks'
|
||||
@@ -18,9 +19,9 @@ import { useNotebooksInfiniteQuery } from '@/data/content/notebooks/notebooks-in
|
||||
import { useAiAssistantChatList } from '@/state/ai-assistant-state'
|
||||
|
||||
export const ExplorerNavHome = ({
|
||||
onSelectSection,
|
||||
onSelectLevel,
|
||||
}: {
|
||||
onSelectSection: (section: ExplorerResourceType) => void
|
||||
onSelectLevel: (level: ExplorerNavLevel) => void
|
||||
}) => {
|
||||
const { ref } = useParams()
|
||||
const { openChat } = useCreateChat()
|
||||
@@ -50,24 +51,22 @@ export const ExplorerNavHome = ({
|
||||
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 }) => {
|
||||
{EXPLORER_SECTIONS.map(({ level, label, icon: Icon }) => {
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
key={level}
|
||||
type="button"
|
||||
tabIndex={0}
|
||||
className={rowClassName(false)}
|
||||
onClick={() => onSelectSection(type)}
|
||||
onClick={() => onSelectLevel(level)}
|
||||
>
|
||||
<Icon size={14} className="shrink-0" />
|
||||
<span className="flex-1 text-left">{label}</span>
|
||||
{type === 'notebook' ? (
|
||||
isPending ? (
|
||||
<ShimmeringLoader className="w-3 py-2" />
|
||||
) : (
|
||||
<span className="text-xs text-foreground-lighter">{notebookCount}</span>
|
||||
)
|
||||
) : (
|
||||
{level === 'notebook' && isPending && <ShimmeringLoader className="w-3 py-2" />}
|
||||
{level === 'notebook' && !isPending && (
|
||||
<span className="text-xs text-foreground-lighter">{notebookCount}</span>
|
||||
)}
|
||||
{level === 'chat' && (
|
||||
<span className="text-xs text-foreground-lighter">{chats.length}</span>
|
||||
)}
|
||||
<ChevronRight size={14} className="shrink-0 text-foreground-muted" />
|
||||
@@ -84,10 +83,10 @@ export const ExplorerNavHome = ({
|
||||
<p className="px-3 text-xs text-foreground-lighter">Nothing edited yet</p>
|
||||
) : (
|
||||
recentItems.map((item) => {
|
||||
const Icon = EXPLORER_SECTIONS.find((section) => section.type === item.type)?.icon
|
||||
const { icon: Icon } = EXPLORER_RESOURCES[item.type]
|
||||
const content = (
|
||||
<>
|
||||
{Icon && <Icon size={14} className="shrink-0" aria-hidden="true" />}
|
||||
<Icon size={14} className="shrink-0" aria-hidden="true" />
|
||||
<span className="flex-1 truncate text-left">{item.label}</span>
|
||||
<span className="shrink-0 text-xs text-foreground-lighter">
|
||||
{formatRelativeTimeShort(item.updatedAt)}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import type { ComponentType, PropsWithChildren } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { ExplorerNavTables } from './ExplorerNavTables'
|
||||
import { entityQueryId } from '@/components/interfaces/Explorer/entityQuery.utils'
|
||||
import { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants'
|
||||
import { customRender } from '@/tests/lib/custom-render'
|
||||
|
||||
const openEntityQuery = vi.fn()
|
||||
|
||||
vi.mock('common', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('common')>()
|
||||
return {
|
||||
...actual,
|
||||
useParams: () => ({ id: entityQueryId({ schema: 'public', name: 'posts' }) }),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('next/router', () => ({
|
||||
useRouter: () => ({ pathname: '/project/[ref]/explorer/query/[id]' }),
|
||||
}))
|
||||
|
||||
vi.mock('./ExplorerLayout.constants', () => ({
|
||||
ExplorerNavPanel: ({ children }: PropsWithChildren) => <div>{children}</div>,
|
||||
rowClassName: (isActive: boolean) => (isActive ? 'active' : 'inactive'),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/SchemaSelector', () => ({ SchemaSelector: () => null }))
|
||||
|
||||
// The virtualizer measures a zero-height container in jsdom and renders no rows, so this
|
||||
// stands in for it to exercise the row itself.
|
||||
type Row = { id: number; schema: string; name: string; type: ENTITY_TYPE }
|
||||
|
||||
vi.mock('@/components/ui/InfiniteList', () => ({
|
||||
LoaderForIconMenuItems: () => null,
|
||||
InfiniteListDefault: ({
|
||||
items,
|
||||
itemProps,
|
||||
ItemComponent,
|
||||
}: {
|
||||
items: Row[]
|
||||
itemProps: object
|
||||
ItemComponent: ComponentType<{ item: Row }>
|
||||
}) => items.map((item) => <ItemComponent key={item.id} item={item} {...itemProps} />),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/interfaces/Explorer/hooks', () => ({
|
||||
useOpenEntityQuery: () => ({ openEntityQuery }),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/misc/useSchemaQueryState', () => ({
|
||||
useQuerySchemaState: () => ({ selectedSchema: 'public', setSelectedSchema: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/misc/useSelectedProject', () => ({
|
||||
useSelectedProjectQuery: () => ({ data: { ref: 'abc', connectionString: 'postgres://' } }),
|
||||
}))
|
||||
|
||||
vi.mock('@/data/entity-types/entity-types-infinite-query', () => ({
|
||||
useEntityTypesQuery: () => ({
|
||||
data: {
|
||||
pages: [
|
||||
{
|
||||
data: {
|
||||
entities: [
|
||||
{ id: 1, schema: 'public', name: 'users', type: ENTITY_TYPE.TABLE },
|
||||
{ id: 2, schema: 'public', name: 'posts', type: ENTITY_TYPE.TABLE },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
isPending: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('ExplorerNavTables', () => {
|
||||
it('marks the table backing the open query and opens the clicked one as a query', async () => {
|
||||
customRender(<ExplorerNavTables onBack={vi.fn()} />)
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'users' })).toBeInTheDocument())
|
||||
expect(screen.getByRole('button', { name: 'posts' })).toHaveClass('active')
|
||||
expect(screen.getByRole('button', { name: 'users' })).toHaveClass('inactive')
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'users' }))
|
||||
|
||||
expect(openEntityQuery).toHaveBeenCalledWith({
|
||||
schema: 'public',
|
||||
name: 'users',
|
||||
type: ENTITY_TYPE.TABLE,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useDebounce } from '@uidotdev/usehooks'
|
||||
import { useParams } from 'common'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { cn } from 'ui'
|
||||
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
import { ExplorerNavPanel, rowClassName } from './ExplorerLayout.constants'
|
||||
import {
|
||||
entityQueryId,
|
||||
TABLE_ENTITY_TYPES,
|
||||
type QueryEntityBinding,
|
||||
} from '@/components/interfaces/Explorer/entityQuery.utils'
|
||||
import { useOpenEntityQuery } from '@/components/interfaces/Explorer/hooks'
|
||||
import { EntityTypeIcon } from '@/components/ui/EntityTypeIcon'
|
||||
import {
|
||||
InfiniteListDefault,
|
||||
LoaderForIconMenuItems,
|
||||
type RowComponentBaseProps,
|
||||
} from '@/components/ui/InfiniteList'
|
||||
import { SchemaSelector } from '@/components/ui/SchemaSelector'
|
||||
import { useEntityTypesQuery, type Entity } from '@/data/entity-types/entity-types-infinite-query'
|
||||
import { useQuerySchemaState } from '@/hooks/misc/useSchemaQueryState'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
|
||||
const TABLE_ROW_HEIGHT = 28
|
||||
|
||||
type TableListItemProps = RowComponentBaseProps<Entity> & {
|
||||
activeQueryId: string | undefined
|
||||
onOpen: (entity: QueryEntityBinding) => void
|
||||
}
|
||||
|
||||
const TableListItem = ({ item: table, style, activeQueryId, onOpen }: TableListItemProps) => {
|
||||
const entity = { schema: table.schema, name: table.name, type: table.type }
|
||||
const isActive = activeQueryId === entityQueryId(entity)
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={0}
|
||||
className={rowClassName(isActive)}
|
||||
style={style}
|
||||
onClick={() => onOpen(entity)}
|
||||
>
|
||||
<EntityTypeIcon type={table.type} size={14} isActive={isActive} />
|
||||
<span className={cn('truncate text-left', isActive && 'text-foreground')}>{table.name}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists the tables of the selected schema. Opening one is not a separate destination — it
|
||||
* creates the query that reads it, so a table lands in the same tab, editor and results as
|
||||
* any other query.
|
||||
*/
|
||||
export const ExplorerNavTables = ({ onBack }: { onBack: () => void }) => {
|
||||
const router = useRouter()
|
||||
const { id } = useParams()
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const { selectedSchema, setSelectedSchema } = useQuerySchemaState()
|
||||
const { openEntityQuery } = useOpenEntityQuery()
|
||||
|
||||
const [search, setSearch] = useState('')
|
||||
const debouncedSearch = useDebounce(search, 500)
|
||||
|
||||
const {
|
||||
data: entitiesData,
|
||||
isPending,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
} = useEntityTypesQuery({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
schemas: [selectedSchema],
|
||||
search: search.length === 0 ? search : debouncedSearch,
|
||||
sort: 'alphabetical',
|
||||
filterTypes: TABLE_ENTITY_TYPES,
|
||||
limit: 100,
|
||||
})
|
||||
|
||||
const tables = useMemo(
|
||||
() => entitiesData?.pages.flatMap((page) => page.data.entities) ?? [],
|
||||
[entitiesData?.pages]
|
||||
)
|
||||
|
||||
const activeQueryId = router.pathname.includes('/explorer/query/') ? id : undefined
|
||||
|
||||
return (
|
||||
<ExplorerNavPanel
|
||||
label="Tables"
|
||||
search={search}
|
||||
setSearch={setSearch}
|
||||
searchPlaceholder="Search tables"
|
||||
onBack={onBack}
|
||||
>
|
||||
<div className="px-3 pb-2">
|
||||
<SchemaSelector
|
||||
size="tiny"
|
||||
selectedSchemaName={selectedSchema}
|
||||
onSelectSchema={setSelectedSchema}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-1 min-h-0 flex-col px-3 pb-3">
|
||||
{isPending && <GenericSkeletonLoader />}
|
||||
{!isPending && tables.length === 0 && (
|
||||
<p className="px-2 py-2 text-xs text-foreground-lighter">
|
||||
{search ? 'No tables found' : 'No tables in this schema'}
|
||||
</p>
|
||||
)}
|
||||
{!isPending && tables.length > 0 && (
|
||||
<InfiniteListDefault
|
||||
className="h-full w-full"
|
||||
items={tables}
|
||||
itemProps={{ activeQueryId, onOpen: openEntityQuery }}
|
||||
ItemComponent={TableListItem}
|
||||
LoaderComponent={LoaderForIconMenuItems}
|
||||
getItemKey={(index) => {
|
||||
const table = tables[index]
|
||||
return table ? `${table.schema}.${table.name}` : `table-${index}`
|
||||
}}
|
||||
getItemSize={() => TABLE_ROW_HEIGHT}
|
||||
gap={1}
|
||||
hasNextPage={hasNextPage}
|
||||
isLoadingNextPage={isFetchingNextPage}
|
||||
onLoadNextPage={fetchNextPage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ExplorerNavPanel>
|
||||
)
|
||||
}
|
||||
@@ -105,7 +105,11 @@ export const SortableTab = ({
|
||||
)}
|
||||
{...listeners}
|
||||
>
|
||||
<EntityTypeIcon type={tab.type} sqlSource={tab.metadata?.sqlSource} />
|
||||
<EntityTypeIcon
|
||||
type={tab.type}
|
||||
sqlSource={tab.metadata?.sqlSource}
|
||||
entityType={tab.metadata?.entityType}
|
||||
/>
|
||||
<div className="flex items-center gap-0">
|
||||
<AnimatePresence mode="popLayout" initial>
|
||||
{shouldShowSchema && (
|
||||
|
||||
@@ -53,6 +53,8 @@ interface EntityTypeIconProps {
|
||||
strokeWidth?: number
|
||||
isActive?: boolean
|
||||
sqlSource?: SqlSnippetSource
|
||||
/** For a `query`, the database object it was opened from, if any. */
|
||||
entityType?: ENTITY_TYPE
|
||||
}
|
||||
|
||||
export const EntityTypeIcon = ({
|
||||
@@ -61,7 +63,15 @@ export const EntityTypeIcon = ({
|
||||
strokeWidth = 1.5,
|
||||
isActive,
|
||||
sqlSource,
|
||||
entityType,
|
||||
}: EntityTypeIconProps) => {
|
||||
// A query opened from a database object reads as that object, not as a generic query.
|
||||
if (type === 'query' && entityType !== undefined) {
|
||||
return (
|
||||
<EntityTypeIcon type={entityType} size={size} strokeWidth={strokeWidth} isActive={isActive} />
|
||||
)
|
||||
}
|
||||
|
||||
if (type === 'sql' && sqlSource === 'logs') {
|
||||
return (
|
||||
<LogsSnippetIcon
|
||||
|
||||
@@ -22,7 +22,10 @@ const QueryPage: NextPageWithLayout = () => {
|
||||
id: createTabId('query', { id }),
|
||||
type: 'query',
|
||||
label: draft.name,
|
||||
metadata: { queryId: id },
|
||||
metadata: {
|
||||
queryId: id,
|
||||
entityType: draft._tag === 'database' ? draft.entity?.type : undefined,
|
||||
},
|
||||
isPreview: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,8 +4,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
createExplorerQueryState,
|
||||
EXPLORER_QUERY_PERSIST_DELAY,
|
||||
hasDiscardableContent,
|
||||
MAX_PERSISTED_EXPLORER_QUERY_DRAFTS,
|
||||
} from './explorer-query'
|
||||
import { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants'
|
||||
|
||||
const createMemoryStorage = () => {
|
||||
const values = new Map<string, string>()
|
||||
@@ -438,4 +440,82 @@ describe('explorer query drafts', () => {
|
||||
expect(state.results['query-1']).toBeUndefined()
|
||||
expect(storage.getItem(LOCAL_STORAGE_KEYS.EXPLORER_QUERY_DRAFTS('project-a'))).toBeNull()
|
||||
})
|
||||
|
||||
it('persists and restores the entity a draft was opened from', () => {
|
||||
const storage = createMemoryStorage()
|
||||
const firstState = createExplorerQueryState(storage)
|
||||
|
||||
firstState.createDraft({
|
||||
id: 'entity-1',
|
||||
projectRef: 'project-a',
|
||||
name: 'public.users',
|
||||
sql: 'select * from public.users',
|
||||
entity: { schema: 'public', name: 'users', type: ENTITY_TYPE.TABLE },
|
||||
})
|
||||
firstState.updateDraft({ id: 'entity-1', sql: 'select id from public.users' })
|
||||
firstState.flushPendingPersistence()
|
||||
|
||||
const secondState = createExplorerQueryState(storage)
|
||||
secondState.restoreDraft({ id: 'entity-1', projectRef: 'project-a' })
|
||||
|
||||
expect(secondState.drafts['entity-1']).toMatchObject({
|
||||
uncheckedSql: 'select id from public.users',
|
||||
entity: { schema: 'public', name: 'users', type: ENTITY_TYPE.TABLE },
|
||||
})
|
||||
})
|
||||
|
||||
it('drops a malformed persisted entity rather than failing to restore the draft', () => {
|
||||
const storage = createMemoryStorage()
|
||||
storage.setItem(
|
||||
LOCAL_STORAGE_KEYS.EXPLORER_QUERY_DRAFTS('project-a'),
|
||||
JSON.stringify({
|
||||
'entity-1': {
|
||||
name: 'public.users',
|
||||
sql: 'select * from public.users',
|
||||
updatedAt: 1,
|
||||
entity: { schema: 'public' },
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
const state = createExplorerQueryState(storage)
|
||||
|
||||
expect(state.restoreDraft({ id: 'entity-1', projectRef: 'project-a' })).toBe(true)
|
||||
expect(state.drafts['entity-1']).toMatchObject({ entity: undefined })
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasDiscardableContent', () => {
|
||||
const entity = { schema: 'public', name: 'users', type: ENTITY_TYPE.TABLE }
|
||||
|
||||
const draftWith = ({ sql, withEntity }: { sql: string; withEntity: boolean }) => {
|
||||
const state = createExplorerQueryState(createMemoryStorage())
|
||||
state.createDraft({
|
||||
id: 'draft',
|
||||
projectRef: 'project-a',
|
||||
sql,
|
||||
entity: withEntity ? entity : undefined,
|
||||
})
|
||||
return state.drafts['draft']!
|
||||
}
|
||||
|
||||
it('treats an empty draft as nothing to discard', () => {
|
||||
expect(hasDiscardableContent(draftWith({ sql: ' ', withEntity: false }))).toBe(false)
|
||||
})
|
||||
|
||||
it('treats any ad-hoc query with content as discardable', () => {
|
||||
expect(hasDiscardableContent(draftWith({ sql: 'select 1', withEntity: false }))).toBe(true)
|
||||
})
|
||||
|
||||
it('treats an unedited entity draft as reproducible, so closing it needs no prompt', () => {
|
||||
expect(
|
||||
hasDiscardableContent(draftWith({ sql: 'select * from public.users', withEntity: true }))
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('treats an edited entity draft as discardable', () => {
|
||||
expect(
|
||||
hasDiscardableContent(draftWith({ sql: 'select id from public.users', withEntity: true }))
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,11 @@ import { LOCAL_STORAGE_KEYS, safeLocalStorage } from 'common'
|
||||
import { proxy, ref, snapshot, useSnapshot } from 'valtio'
|
||||
import { z } from 'zod'
|
||||
|
||||
import {
|
||||
buildEntitySelectSql,
|
||||
queryEntityBindingSchema,
|
||||
type QueryEntityBinding,
|
||||
} from '@/components/interfaces/Explorer/entityQuery.utils'
|
||||
import { DEFAULT_CELL_ROW_LIMIT } from '@/components/interfaces/Explorer/QueryCell/QueryCell.utils'
|
||||
import { type QueryDisplay, type QueryResult } from '@/components/interfaces/Explorer/types'
|
||||
import { ROWS_PER_PAGE_OPTIONS } from '@/components/interfaces/SQLEditor/SQLEditor.constants'
|
||||
@@ -41,6 +46,7 @@ export type DatabaseQueryDraft = ExplorerQueryDraftBase &
|
||||
uncheckedSql: UntrustedSqlFragment
|
||||
rowLimit: number
|
||||
role?: ImpersonationRole
|
||||
entity?: QueryEntityBinding
|
||||
}
|
||||
|
||||
export type LogsQueryDraft = ExplorerQueryDraftBase &
|
||||
@@ -67,6 +73,7 @@ type PersistedExplorerQueryDraft = {
|
||||
updatedAt: number
|
||||
rowLimit?: number
|
||||
role?: ImpersonationRole
|
||||
entity?: QueryEntityBinding
|
||||
} & QueryDisplay
|
||||
|
||||
type PersistedExplorerQueryDrafts = Record<string, PersistedExplorerQueryDraft>
|
||||
@@ -84,6 +91,7 @@ const persistedDraftSchema = z.object({
|
||||
source: z.unknown().optional(),
|
||||
rowLimit: z.unknown().optional(),
|
||||
role: z.unknown().optional(),
|
||||
entity: z.unknown().optional(),
|
||||
view: z.unknown().optional(),
|
||||
chart: z.unknown().optional(),
|
||||
})
|
||||
@@ -142,6 +150,7 @@ const toDraft = ({
|
||||
uncheckedSql: untrustedSql(persisted.sql),
|
||||
rowLimit: persisted.rowLimit ?? DEFAULT_CELL_ROW_LIMIT,
|
||||
role: persisted.role,
|
||||
entity: persisted.entity,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,6 +179,8 @@ const readPersistedDrafts = (storage: StorageLike, projectRef: string) => {
|
||||
const view = parsedView.success ? parsedView.data : 'table'
|
||||
const parsedChart = chartConfigSchema.safeParse(draft.data.chart)
|
||||
const chart = parsedChart.success ? parsedChart.data : undefined
|
||||
const parsedEntity = queryEntityBindingSchema.safeParse(draft.data.entity)
|
||||
const entity = parsedEntity.success ? parsedEntity.data : undefined
|
||||
|
||||
return [
|
||||
[
|
||||
@@ -183,6 +194,7 @@ const readPersistedDrafts = (storage: StorageLike, projectRef: string) => {
|
||||
rowLimit,
|
||||
view,
|
||||
chart,
|
||||
entity,
|
||||
},
|
||||
],
|
||||
]
|
||||
@@ -209,6 +221,22 @@ const writePersistedDrafts = (
|
||||
else storage.setItem(key, JSON.stringify(retainedDrafts))
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether closing a draft would actually lose something. An entity-backed draft still sitting
|
||||
* on its generated `select *` is reproducible — reopening the table rebuilds it — so only a
|
||||
* draft the user has since edited (or an ad-hoc query with any content at all) is worth a
|
||||
* discard prompt.
|
||||
*/
|
||||
export const hasDiscardableContent = (draft: ExplorerQueryDraft) => {
|
||||
const sql = draft.uncheckedSql.trim()
|
||||
if (sql.length === 0) return false
|
||||
|
||||
const entity = draft._tag === 'database' ? draft.entity : undefined
|
||||
if (!entity) return true
|
||||
|
||||
return sql !== buildEntitySelectSql(entity).trim()
|
||||
}
|
||||
|
||||
export const createExplorerQueryState = (storage: StorageLike = safeLocalStorage) => {
|
||||
const pendingPersistence = new Map<
|
||||
string,
|
||||
@@ -224,6 +252,7 @@ export const createExplorerQueryState = (storage: StorageLike = safeLocalStorage
|
||||
updatedAt: draft.updatedAt,
|
||||
rowLimit: draft._tag === 'database' ? draft.rowLimit : undefined,
|
||||
role: draft._tag === 'database' ? draft.role : undefined,
|
||||
entity: draft._tag === 'database' ? draft.entity : undefined,
|
||||
view: draft.view,
|
||||
chart: draft.chart,
|
||||
}
|
||||
@@ -241,6 +270,7 @@ export const createExplorerQueryState = (storage: StorageLike = safeLocalStorage
|
||||
sql = '',
|
||||
source = createDefaultSourceBinding('database'),
|
||||
rowLimit = DEFAULT_CELL_ROW_LIMIT,
|
||||
entity,
|
||||
}: {
|
||||
id: string
|
||||
projectRef: string
|
||||
@@ -248,6 +278,7 @@ export const createExplorerQueryState = (storage: StorageLike = safeLocalStorage
|
||||
sql?: string
|
||||
source?: QuerySourceBinding
|
||||
rowLimit?: number
|
||||
entity?: QueryEntityBinding
|
||||
}) => {
|
||||
const draft = toDraft({
|
||||
id,
|
||||
@@ -258,6 +289,7 @@ export const createExplorerQueryState = (storage: StorageLike = safeLocalStorage
|
||||
sql,
|
||||
updatedAt: Date.now(),
|
||||
rowLimit,
|
||||
entity,
|
||||
view: 'table',
|
||||
},
|
||||
})
|
||||
@@ -311,6 +343,7 @@ export const createExplorerQueryState = (storage: StorageLike = safeLocalStorage
|
||||
|
||||
const currentRowLimit = draft._tag === 'database' ? draft.rowLimit : undefined
|
||||
const currentRole = draft._tag === 'database' ? draft.role : undefined
|
||||
const currentEntity = draft._tag === 'database' ? draft.entity : undefined
|
||||
|
||||
state.drafts[id] = toDraft({
|
||||
id,
|
||||
@@ -322,6 +355,7 @@ export const createExplorerQueryState = (storage: StorageLike = safeLocalStorage
|
||||
updatedAt: Date.now(),
|
||||
rowLimit: rowLimit ?? currentRowLimit,
|
||||
role: currentRole,
|
||||
entity: currentEntity,
|
||||
view: draft.view,
|
||||
chart: draft.chart,
|
||||
},
|
||||
|
||||
@@ -92,6 +92,12 @@ export interface Tab {
|
||||
* it never goes stale once set).
|
||||
*/
|
||||
sqlSource?: SqlSnippetSource
|
||||
/**
|
||||
* For query tabs opened from a database object rather than typed from scratch, which
|
||||
* entity backs the query — so the tab shows that object's icon instead of a generic
|
||||
* query one. Absent on ad-hoc queries.
|
||||
*/
|
||||
entityType?: ENTITY_TYPE
|
||||
}
|
||||
isPreview?: boolean
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user