Merge dnywh/studio/sql-editor-ia into warehouse-wholesale
Bring SQL Editor query context bar (connection, search path, role) and integrate Warehouse query target into those controls instead of a standalone Postgres/Warehouse toggle.
No files matched your search
@@ -13,6 +13,7 @@ export interface RoleImpersonationPopoverProps {
|
||||
variant?: 'regular' | 'connected-on-right' | 'connected-on-left' | 'connected-on-both'
|
||||
align?: 'center' | 'start' | 'end'
|
||||
disallowAuthenticatedOption?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const RoleImpersonationPopover = ({
|
||||
@@ -21,6 +22,7 @@ export const RoleImpersonationPopover = ({
|
||||
variant = 'regular',
|
||||
align = 'end',
|
||||
disallowAuthenticatedOption = false,
|
||||
className,
|
||||
}: RoleImpersonationPopoverProps) => {
|
||||
const state = useRoleImpersonationStateSnapshot()
|
||||
|
||||
@@ -38,7 +40,8 @@ export const RoleImpersonationPopover = ({
|
||||
'h-[26px] pr-3 gap-0',
|
||||
variant === 'connected-on-right' && 'rounded-r-none border-r-0',
|
||||
variant === 'connected-on-left' && 'rounded-l-none border-l-0',
|
||||
variant === 'connected-on-both' && 'rounded-none border-x-0'
|
||||
variant === 'connected-on-both' && 'rounded-none border-x-0',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
|
||||
@@ -100,7 +100,7 @@ export const ASSISTANT_TEMPLATES = [
|
||||
]
|
||||
|
||||
export const ROWS_PER_PAGE_OPTIONS = [
|
||||
{ value: -1, label: 'No limit' },
|
||||
{ value: -1, label: 'None' },
|
||||
{ value: 100, label: '100 rows' },
|
||||
{ value: 500, label: '500 rows' },
|
||||
{ value: 1000, label: '1,000 rows' },
|
||||
|
||||
@@ -9,32 +9,17 @@ import {
|
||||
import { wrapWithRollback } from '@supabase/pg-meta/src/query'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { IS_PLATFORM, LOCAL_STORAGE_KEYS, useFlag, useParams } from 'common'
|
||||
import { ChevronUp, Loader2 } from 'lucide-react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Button,
|
||||
cn,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from 'ui'
|
||||
import { cn, ResizableHandle, ResizablePanel, ResizablePanelGroup } from 'ui'
|
||||
|
||||
import { useSqlEditorDiff, useSqlEditorPrompt } from './hooks'
|
||||
import { RunQueryWarningModal } from './RunQueryWarningModal'
|
||||
import {
|
||||
generateSnippetTitle,
|
||||
ROWS_PER_PAGE_OPTIONS,
|
||||
sqlAiDisclaimerComment,
|
||||
untitledSnippetTitle,
|
||||
} from './SQLEditor.constants'
|
||||
@@ -56,7 +41,13 @@ import {
|
||||
isUpdateWithoutWhere,
|
||||
suffixWithLimit,
|
||||
} from './SQLEditor.utils'
|
||||
import { useSqlQueryTarget } from './SqlWarehouseTargetToggle'
|
||||
import { SqlEditorQueryBar } from './SqlEditorQueryBar'
|
||||
import { useSqlQueryTarget } from './SqlEditorQueryTargetSelector'
|
||||
import {
|
||||
DEFAULT_SQL_EDITOR_SEARCH_PATH,
|
||||
getSqlEditorSearchPathStorageKey,
|
||||
} from './SqlEditorSearchPathSelector'
|
||||
import { resolveSqlWarehouseResultSource } from './SqlEditorWarehouseDemo'
|
||||
import { useAddDefinitions } from './useAddDefinitions'
|
||||
import { UtilityPanel } from './UtilityPanel/UtilityPanel'
|
||||
import {
|
||||
@@ -66,7 +57,6 @@ import {
|
||||
} from '@/components/interfaces/ExplainVisualizer/ExplainVisualizer.utils'
|
||||
import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
|
||||
import ResizableAIWidget from '@/components/ui/AIEditor/ResizableAIWidget'
|
||||
import { GridFooter } from '@/components/ui/GridFooter'
|
||||
import { useSqlTitleGenerateMutation } from '@/data/ai/sql-title-mutation'
|
||||
import { useDatabaseEventTriggersQuery } from '@/data/database-event-triggers/database-event-triggers-query'
|
||||
import { constructHeaders, isValidConnString } from '@/data/fetchers'
|
||||
@@ -74,6 +64,7 @@ import { lintKeys } from '@/data/lint/keys'
|
||||
import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query'
|
||||
import { useExecuteSqlMutation } from '@/data/sql/execute-sql-mutation'
|
||||
import { isError } from '@/data/utils/error-check'
|
||||
import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
|
||||
import { useOrgAiOptInLevel } from '@/hooks/misc/useOrgOptedIntoAi'
|
||||
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
@@ -128,6 +119,10 @@ export const SQLEditor = () => {
|
||||
const sessionSnap = useSqlEditorSessionSnapshot()
|
||||
const diffRequest = useSqlEditorDiffRequestSnapshot()
|
||||
const getImpersonatedRoleState = useGetImpersonatedRoleState()
|
||||
const [searchPath] = useLocalStorageQuery(
|
||||
getSqlEditorSearchPathStorageKey(ref ?? 'unknown'),
|
||||
DEFAULT_SQL_EDITOR_SEARCH_PATH
|
||||
)
|
||||
const databaseSelectorState = useDatabaseSelectorStateSnapshot()
|
||||
const { aiOptInLevel } = useOrgAiOptInLevel()
|
||||
const sqlQueryTarget = useSqlQueryTarget()
|
||||
@@ -209,7 +204,6 @@ export const SQLEditor = () => {
|
||||
const id = !urlId || urlId === 'new' ? generatedId : urlId
|
||||
|
||||
const limit = sessionSnap.limit
|
||||
const results = sessionSnap.results[id]?.[0]
|
||||
const snippetIsLoading = !(
|
||||
id in snapV2.snippets && snapV2.snippets[id].snippet.content !== undefined
|
||||
)
|
||||
@@ -238,7 +232,7 @@ export const SQLEditor = () => {
|
||||
const { mutate: execute, isPending: isExecuting } = useExecuteSqlMutation({
|
||||
onSuccess(data, vars) {
|
||||
if (id) {
|
||||
sessionSnap.addResult(id, data.result, vars.autoLimit)
|
||||
sessionSnap.addResult(id, data.result, vars.autoLimit, vars.warehouseResultSource)
|
||||
|
||||
if (!disablePrettyExplain && isExplainQuery(data.result)) {
|
||||
sessionSnap.addExplainResult(id, data.result)
|
||||
@@ -286,7 +280,7 @@ export const SQLEditor = () => {
|
||||
}
|
||||
}
|
||||
|
||||
sessionSnap.addResultError(id, error, vars.autoLimit)
|
||||
sessionSnap.addResultError(id, error, vars.autoLimit, vars.warehouseResultSource)
|
||||
}
|
||||
|
||||
refocusEditorAfterRunIfNeeded()
|
||||
@@ -436,12 +430,18 @@ export const SQLEditor = () => {
|
||||
const userSql = rawSql(sql)
|
||||
const { appendAutoLimit } = checkIfAppendLimitRequired(userSql, limit)
|
||||
const formattedSql = suffixWithLimit(userSql, limit)
|
||||
const warehouseResultSource = resolveSqlWarehouseResultSource(
|
||||
userSql,
|
||||
searchPath,
|
||||
sqlQueryTarget
|
||||
)
|
||||
|
||||
execute({
|
||||
projectRef: project.ref,
|
||||
connectionString: connectionString,
|
||||
sql: wrapWithRoleImpersonation(formattedSql, impersonatedRoleState),
|
||||
autoLimit: appendAutoLimit ? limit : undefined,
|
||||
warehouseResultSource,
|
||||
isRoleImpersonationEnabled: isRoleImpersonationEnabled(impersonatedRoleState.role),
|
||||
isStatementTimeoutDisabled: true,
|
||||
contextualInvalidation: true,
|
||||
@@ -467,6 +467,8 @@ export const SQLEditor = () => {
|
||||
databases,
|
||||
eventTriggers,
|
||||
limit,
|
||||
searchPath,
|
||||
sqlQueryTarget,
|
||||
track,
|
||||
]
|
||||
)
|
||||
@@ -548,7 +550,7 @@ export const SQLEditor = () => {
|
||||
databaseSelectorState.selectedDatabaseId,
|
||||
databases,
|
||||
lineHighlights,
|
||||
sessionSnap,
|
||||
snapV2,
|
||||
])
|
||||
|
||||
useShortcut(SHORTCUT_IDS.SQL_EDITOR_EXPLAIN, executeExplainQuery, {
|
||||
@@ -885,6 +887,121 @@ export const SQLEditor = () => {
|
||||
}
|
||||
}, [isDiffOpen, isDiffEditorMounted])
|
||||
|
||||
const queryResult = sessionSnap.results[id]?.[0]
|
||||
const explainResult = sessionSnap.explainResults[id]
|
||||
const hasExplainResult =
|
||||
explainResult?.error !== undefined || (explainResult?.rows?.length ?? 0) > 0
|
||||
const showUtilityPanel =
|
||||
isExecuting || isExplainExecuting || queryResult !== undefined || hasExplainResult
|
||||
|
||||
const sqlEditorWorkspace = isLoading ? (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Loader2 className="animate-spin text-brand" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{isDiffOpen && (
|
||||
<div className="w-full h-full">
|
||||
<DiffEditor
|
||||
language="pgsql"
|
||||
original={defaultSqlDiff.original}
|
||||
modified={defaultSqlDiff.modified}
|
||||
onMount={(editor) => {
|
||||
diffEditorRef.current = editor
|
||||
setIsDiffEditorMounted(true)
|
||||
}}
|
||||
/>
|
||||
{showWidget && (
|
||||
<ResizableAIWidget
|
||||
editor={diffEditorRef.current!}
|
||||
id="ask-ai-diff"
|
||||
value={promptInput}
|
||||
onChange={setPromptInput}
|
||||
onSubmit={(prompt: string) => {
|
||||
handlePrompt(prompt, {
|
||||
beforeSelection: promptState.beforeSelection,
|
||||
selection: promptState.selection || defaultSqlDiff.modified,
|
||||
afterSelection: promptState.afterSelection,
|
||||
})
|
||||
}}
|
||||
onAccept={acceptAiHandler}
|
||||
onReject={discardAiHandler}
|
||||
onCancel={resetPrompt}
|
||||
isDiffVisible={true}
|
||||
isLoading={isCompletionLoading}
|
||||
startLineNumber={Math.max(0, promptState.startLineNumber)}
|
||||
endLineNumber={promptState.endLineNumber}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div key={id} className="w-full h-full relative">
|
||||
<MonacoEditor
|
||||
autoFocus
|
||||
placeholder={
|
||||
!promptState.isOpen && !editorRef.current?.getValue()
|
||||
? 'Hit ' +
|
||||
(os === 'macos' ? 'CMD+SHIFT+K' : `CTRL+SHIFT+K`) +
|
||||
' to generate query or just start typing'
|
||||
: ''
|
||||
}
|
||||
id={id}
|
||||
snippetName={
|
||||
urlId === 'new'
|
||||
? generatedNewSnippetName
|
||||
: (snapV2.snippets[id]?.snippet.name ?? generatedNewSnippetName)
|
||||
}
|
||||
className={cn(isDiffOpen && 'hidden')}
|
||||
editorRef={editorRef}
|
||||
monacoRef={monacoRef}
|
||||
executeQuery={executeQuery}
|
||||
executeExplainQuery={executeExplainQuery}
|
||||
showExplainAction={!disablePrettyExplain}
|
||||
prettifyQuery={prettifyQuery}
|
||||
onHasSelection={setHasSelection}
|
||||
onMount={onMount}
|
||||
onPrompt={({
|
||||
selection,
|
||||
beforeSelection,
|
||||
afterSelection,
|
||||
startLineNumber,
|
||||
endLineNumber,
|
||||
}) => {
|
||||
setPromptState((prev) => ({
|
||||
...prev,
|
||||
isOpen: true,
|
||||
selection,
|
||||
beforeSelection,
|
||||
afterSelection,
|
||||
startLineNumber,
|
||||
endLineNumber,
|
||||
}))
|
||||
}}
|
||||
/>
|
||||
{editorRef.current && promptState.isOpen && !isDiffOpen && (
|
||||
<ResizableAIWidget
|
||||
editor={editorRef.current}
|
||||
id="ask-ai"
|
||||
value={promptInput}
|
||||
onChange={setPromptInput}
|
||||
onSubmit={(prompt: string) => {
|
||||
handlePrompt(prompt, {
|
||||
beforeSelection: promptState.beforeSelection,
|
||||
selection: promptState.selection,
|
||||
afterSelection: promptState.afterSelection,
|
||||
})
|
||||
}}
|
||||
onCancel={resetPrompt}
|
||||
isDiffVisible={false}
|
||||
isLoading={isCompletionLoading}
|
||||
startLineNumber={Math.max(0, promptState.startLineNumber)}
|
||||
endLineNumber={promptState.endLineNumber}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<RunQueryWarningModal
|
||||
@@ -918,140 +1035,35 @@ export const SQLEditor = () => {
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex h-full">
|
||||
<ResizablePanelGroup
|
||||
className="relative"
|
||||
orientation="vertical"
|
||||
autoSaveId={LOCAL_STORAGE_KEYS.SQL_EDITOR_SPLIT_SIZE}
|
||||
>
|
||||
<ResizablePanel defaultSize="50" maxSize="70">
|
||||
<div className="grow overflow-y-auto border-b h-full">
|
||||
{isLoading ? (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Loader2 className="animate-spin text-brand" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{isDiffOpen && (
|
||||
<div className="w-full h-full">
|
||||
<DiffEditor
|
||||
language="pgsql"
|
||||
original={defaultSqlDiff.original}
|
||||
modified={defaultSqlDiff.modified}
|
||||
onMount={(editor) => {
|
||||
diffEditorRef.current = editor
|
||||
setIsDiffEditorMounted(true)
|
||||
}}
|
||||
/>
|
||||
{showWidget && (
|
||||
<ResizableAIWidget
|
||||
editor={diffEditorRef.current!}
|
||||
id="ask-ai-diff"
|
||||
value={promptInput}
|
||||
onChange={setPromptInput}
|
||||
onSubmit={(prompt: string) => {
|
||||
handlePrompt(prompt, {
|
||||
beforeSelection: promptState.beforeSelection,
|
||||
selection: promptState.selection || defaultSqlDiff.modified,
|
||||
afterSelection: promptState.afterSelection,
|
||||
})
|
||||
}}
|
||||
onAccept={acceptAiHandler}
|
||||
onReject={discardAiHandler}
|
||||
onCancel={resetPrompt}
|
||||
isDiffVisible={true}
|
||||
isLoading={isCompletionLoading}
|
||||
startLineNumber={Math.max(0, promptState.startLineNumber)}
|
||||
endLineNumber={promptState.endLineNumber}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div key={id} className="w-full h-full relative">
|
||||
<MonacoEditor
|
||||
autoFocus
|
||||
placeholder={
|
||||
!promptState.isOpen && !editorRef.current?.getValue()
|
||||
? 'Hit ' +
|
||||
(os === 'macos' ? 'CMD+SHIFT+K' : `CTRL+SHIFT+K`) +
|
||||
' to generate query or just start typing'
|
||||
: ''
|
||||
}
|
||||
id={id}
|
||||
snippetName={
|
||||
urlId === 'new'
|
||||
? generatedNewSnippetName
|
||||
: (snapV2.snippets[id]?.snippet.name ?? generatedNewSnippetName)
|
||||
}
|
||||
className={cn(isDiffOpen && 'hidden')}
|
||||
editorRef={editorRef}
|
||||
monacoRef={monacoRef}
|
||||
executeQuery={executeQuery}
|
||||
executeExplainQuery={executeExplainQuery}
|
||||
showExplainAction={!disablePrettyExplain}
|
||||
prettifyQuery={prettifyQuery}
|
||||
onHasSelection={setHasSelection}
|
||||
onMount={onMount}
|
||||
onPrompt={({
|
||||
selection,
|
||||
beforeSelection,
|
||||
afterSelection,
|
||||
startLineNumber,
|
||||
endLineNumber,
|
||||
}) => {
|
||||
setPromptState((prev) => ({
|
||||
...prev,
|
||||
isOpen: true,
|
||||
selection,
|
||||
beforeSelection,
|
||||
afterSelection,
|
||||
startLineNumber,
|
||||
endLineNumber,
|
||||
}))
|
||||
}}
|
||||
/>
|
||||
{editorRef.current && promptState.isOpen && !isDiffOpen && (
|
||||
<ResizableAIWidget
|
||||
editor={editorRef.current}
|
||||
id="ask-ai"
|
||||
value={promptInput}
|
||||
onChange={setPromptInput}
|
||||
onSubmit={(prompt: string) => {
|
||||
handlePrompt(prompt, {
|
||||
beforeSelection: promptState.beforeSelection,
|
||||
selection: promptState.selection,
|
||||
afterSelection: promptState.afterSelection,
|
||||
})
|
||||
}}
|
||||
onCancel={resetPrompt}
|
||||
isDiffVisible={false}
|
||||
isLoading={isCompletionLoading}
|
||||
startLineNumber={Math.max(0, promptState.startLineNumber)}
|
||||
endLineNumber={promptState.endLineNumber}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<div className="flex h-full flex-col">
|
||||
{!isLoading && (
|
||||
<SqlEditorQueryBar
|
||||
id={id}
|
||||
isExecuting={isExecuting}
|
||||
isDisabled={isDiffOpen}
|
||||
hasSelection={hasSelection}
|
||||
prettifyQuery={prettifyQuery}
|
||||
executeQuery={executeQueryFromButton}
|
||||
/>
|
||||
)}
|
||||
{showUtilityPanel ? (
|
||||
<ResizablePanelGroup
|
||||
className="relative min-h-0 flex-1"
|
||||
orientation="vertical"
|
||||
autoSaveId={LOCAL_STORAGE_KEYS.SQL_EDITOR_SPLIT_SIZE}
|
||||
>
|
||||
<ResizablePanel defaultSize="50" maxSize="70">
|
||||
<div className="grow overflow-y-auto h-full">{sqlEditorWorkspace}</div>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
<ResizablePanel defaultSize="50" maxSize="70">
|
||||
{isLoading ? (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Loader2 className="animate-spin text-brand" />
|
||||
</div>
|
||||
) : (
|
||||
<ResizablePanel defaultSize="50" maxSize="70">
|
||||
<UtilityPanel
|
||||
id={id}
|
||||
isExecuting={isExecuting}
|
||||
isExplainExecuting={isExplainExecuting}
|
||||
isDisabled={isDiffOpen}
|
||||
hasSelection={hasSelection}
|
||||
prettifyQuery={prettifyQuery}
|
||||
executeQuery={executeQueryFromButton}
|
||||
executeExplainQuery={executeExplainQuery}
|
||||
showExplainTab={!disablePrettyExplain}
|
||||
onDebug={onDebug}
|
||||
@@ -1059,71 +1071,11 @@ export const SQLEditor = () => {
|
||||
activeTab={activeUtilityTab}
|
||||
onActiveTabChange={setActiveUtilityTab}
|
||||
/>
|
||||
)}
|
||||
</ResizablePanel>
|
||||
|
||||
<div className="h-9">
|
||||
{results?.rows !== undefined && !isExecuting && (
|
||||
<GridFooter className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<p className="text-xs">
|
||||
<span className="text-foreground">
|
||||
{results.rows.length} row{results.rows.length > 1 ? 's' : ''}
|
||||
</span>
|
||||
<span className="text-foreground-lighter ml-1">
|
||||
{results.autoLimit !== undefined &&
|
||||
` (Limited to only ${results.autoLimit} rows)`}
|
||||
</span>
|
||||
</p>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
<p className="flex flex-col gap-y-1">
|
||||
<span>
|
||||
Results are automatically limited to preserve browser performance, in
|
||||
particular if your query returns an exceptionally large number of rows.
|
||||
</span>
|
||||
|
||||
<span className="text-foreground-light">
|
||||
You may change or remove this limit from the dropdown on the right
|
||||
</span>
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{sqlQueryTarget === 'warehouse' && (
|
||||
<p className="text-xs text-foreground-light">Served by: Warehouse</p>
|
||||
)}
|
||||
</div>
|
||||
{results.autoLimit !== undefined && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="default" iconRight={<ChevronUp size={14} />}>
|
||||
Limit results to:{' '}
|
||||
{
|
||||
ROWS_PER_PAGE_OPTIONS.find((opt) => opt.value === sessionSnap.limit)
|
||||
?.label
|
||||
}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-40" align="end">
|
||||
<DropdownMenuRadioGroup
|
||||
value={sessionSnap.limit.toString()}
|
||||
onValueChange={(val) => sessionSnap.setLimit(Number(val))}
|
||||
>
|
||||
{ROWS_PER_PAGE_OPTIONS.map((option) => (
|
||||
<DropdownMenuRadioItem key={option.label} value={option.value.toString()}>
|
||||
{option.label}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</GridFooter>
|
||||
)}
|
||||
</div>
|
||||
</ResizablePanelGroup>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
) : (
|
||||
<div className="relative min-h-0 flex-1 overflow-y-auto">{sqlEditorWorkspace}</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
import { Hotkey } from '@tanstack/react-hotkeys'
|
||||
import { LOCAL_STORAGE_KEYS, useParams } from 'common'
|
||||
import { AlignLeft, ChevronDown, MoreVertical } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
KeyboardShortcut,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from 'ui'
|
||||
import { useSnapshot } from 'valtio'
|
||||
|
||||
import { SqlEditorQueryTargetSelector, useSqlQueryTarget } from './SqlEditorQueryTargetSelector'
|
||||
import {
|
||||
DEFAULT_SQL_EDITOR_SEARCH_PATH,
|
||||
getSqlEditorSearchPathStorageKey,
|
||||
SqlEditorSearchPathSelector,
|
||||
} from './SqlEditorSearchPathSelector'
|
||||
import { sqlEditorWarehouseDemoStore } from './SqlEditorWarehouseDemo'
|
||||
import { AutosaveStatus } from './UtilityPanel/AutosaveStatus'
|
||||
import { SqlRunButton } from './UtilityPanel/RunButton'
|
||||
import { SqlSaveButton } from './UtilityPanel/SaveButton'
|
||||
import SavingIndicator from './UtilityPanel/SavingIndicator'
|
||||
import { SqlEditorLimitSelector } from './UtilityPanel/SqlEditorLimitSelector'
|
||||
import { useIsSqlEditorManualSaveEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
|
||||
import { RoleImpersonationPopover } from '@/components/interfaces/RoleImpersonationSelector/RoleImpersonationPopover'
|
||||
import { DatabaseSelector } from '@/components/ui/DatabaseSelector'
|
||||
import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
|
||||
import { IS_PLATFORM } from '@/lib/constants'
|
||||
import { useRoleImpersonationStateSnapshot } from '@/state/role-impersonation-state'
|
||||
import { hotkeyToKeys } from '@/state/shortcuts/formatShortcut'
|
||||
import { SHORTCUT_DEFINITIONS, SHORTCUT_IDS } from '@/state/shortcuts/registry'
|
||||
import { useSqlEditorSessionSnapshot } from '@/state/sql-editor/sql-editor-session-state'
|
||||
import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor/sql-editor-state'
|
||||
|
||||
export type SqlEditorQueryBarProps = {
|
||||
id: string
|
||||
isExecuting?: boolean
|
||||
isDisabled?: boolean
|
||||
hasSelection?: boolean
|
||||
prettifyQuery: () => void
|
||||
executeQuery: () => void
|
||||
}
|
||||
|
||||
const SEPARATE_QUERY_CONTROLS_INLINE_MIN_WIDTH = 896
|
||||
|
||||
export function SqlEditorQueryBar({
|
||||
id,
|
||||
isExecuting = false,
|
||||
isDisabled = false,
|
||||
hasSelection = false,
|
||||
prettifyQuery,
|
||||
executeQuery,
|
||||
}: SqlEditorQueryBarProps) {
|
||||
const { ref } = useParams()
|
||||
const snapV2 = useSqlEditorV2StateSnapshot()
|
||||
const sessionSnap = useSqlEditorSessionSnapshot()
|
||||
const isManualSaveEnabled = useIsSqlEditorManualSaveEnabled()
|
||||
const roleImpersonationState = useRoleImpersonationStateSnapshot()
|
||||
const warehouseDemoSnap = useSnapshot(sqlEditorWarehouseDemoStore)
|
||||
const queryBarRef = useRef<HTMLDivElement>(null)
|
||||
const [areSeparateQueryControlsCollapsed, setAreSeparateQueryControlsCollapsed] = useState(false)
|
||||
|
||||
const [intelliSenseEnabled, setIntellisenseEnabled] = useLocalStorageQuery(
|
||||
LOCAL_STORAGE_KEYS.SQL_EDITOR_INTELLISENSE,
|
||||
true
|
||||
)
|
||||
const [lastSelectedDb, setLastSelectedDb] = useLocalStorageQuery(
|
||||
LOCAL_STORAGE_KEYS.SQL_EDITOR_LAST_SELECTED_DB(ref as string),
|
||||
''
|
||||
)
|
||||
const [searchPath] = useLocalStorageQuery(
|
||||
getSqlEditorSearchPathStorageKey(ref ?? 'unknown'),
|
||||
DEFAULT_SQL_EDITOR_SEARCH_PATH
|
||||
)
|
||||
const queryTarget = useSqlQueryTarget()
|
||||
|
||||
const snippet = snapV2.snippets[id]
|
||||
const isFavorite = snippet !== undefined ? snippet.snippet.favorite : false
|
||||
const currentRole = roleImpersonationState.role?.role ?? 'postgres'
|
||||
|
||||
const hotkeySequnece: Hotkey | undefined =
|
||||
SHORTCUT_DEFINITIONS[SHORTCUT_IDS.SQL_EDITOR_FORMAT].sequence[0]
|
||||
const formatKeys = hotkeySequnece ? hotkeyToKeys(hotkeySequnece) : undefined
|
||||
|
||||
const handleIntelliSenseChange = (checked: boolean) => {
|
||||
setIntellisenseEnabled(checked)
|
||||
toast.success(
|
||||
`Successfully ${checked ? 'enabled' : 'disabled'} IntelliSense. ${checked ? '' : 'Please refresh your browser for changes to take place.'}`
|
||||
)
|
||||
}
|
||||
|
||||
const toggleFavorite = () => {
|
||||
if (isFavorite) {
|
||||
snapV2.removeFavorite(id)
|
||||
toast.success('Removed from favourites')
|
||||
} else {
|
||||
snapV2.addFavorite(id)
|
||||
toast.success('Added to favourites')
|
||||
}
|
||||
}
|
||||
|
||||
const onSelectDatabase = (databaseId: string) => {
|
||||
sessionSnap.resetResults(id)
|
||||
setLastSelectedDb(databaseId)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const queryBar = queryBarRef.current
|
||||
if (!queryBar) return
|
||||
|
||||
const updateQueryControlsVisibility = (width: number) => {
|
||||
const shouldCollapse = width < SEPARATE_QUERY_CONTROLS_INLINE_MIN_WIDTH
|
||||
setAreSeparateQueryControlsCollapsed((current) =>
|
||||
current === shouldCollapse ? current : shouldCollapse
|
||||
)
|
||||
}
|
||||
|
||||
updateQueryControlsVisibility(queryBar.getBoundingClientRect().width)
|
||||
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
updateQueryControlsVisibility(entry.contentRect.width)
|
||||
})
|
||||
observer.observe(queryBar)
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
const isSeparateQueryControlMode = warehouseDemoSnap.queryControlMode === 'separate'
|
||||
const shouldShowCollapsedQueryControls =
|
||||
isSeparateQueryControlMode && areSeparateQueryControlsCollapsed
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={queryBarRef}
|
||||
className="flex h-10 shrink-0 items-center justify-between border-b bg-dash-sidebar px-2 dark:bg-surface-100 @container"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-x-2">
|
||||
{IS_PLATFORM && <AutosaveStatus />}
|
||||
{/* SavingIndicator reports auto-save progress. In manual mode the Save button owns status. */}
|
||||
{IS_PLATFORM && !isManualSaveEnabled && <SavingIndicator id={id} />}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-x-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
data-testid="sql-editor-utility-actions"
|
||||
variant="default"
|
||||
icon={<MoreVertical />}
|
||||
className="h-[26px] w-[26px]"
|
||||
aria-label="Query options"
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-72">
|
||||
{shouldShowCollapsedQueryControls && (
|
||||
<>
|
||||
<div className="space-y-1 px-1 py-1.5">
|
||||
<p className="px-2 py-1 text-xs text-foreground-lighter">Query context</p>
|
||||
<SeparateQueryControls
|
||||
onSelectDatabase={onSelectDatabase}
|
||||
selectedDatabaseId={lastSelectedDb}
|
||||
fullWidth
|
||||
/>
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuLabel className="text-xs font-normal text-foreground-lighter">
|
||||
Actions
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuItem className="justify-between" onClick={prettifyQuery}>
|
||||
<span className="flex items-center gap-x-2">
|
||||
<AlignLeft size={14} strokeWidth={2} className="text-foreground-light" />
|
||||
Prettify SQL
|
||||
</span>
|
||||
{formatKeys && <KeyboardShortcut keys={formatKeys} />}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className="text-xs font-normal text-foreground-lighter">
|
||||
Preferences
|
||||
</DropdownMenuLabel>
|
||||
{IS_PLATFORM && (
|
||||
<DropdownMenuCheckboxItem checked={isFavorite} onCheckedChange={toggleFavorite}>
|
||||
Favourite query
|
||||
</DropdownMenuCheckboxItem>
|
||||
)}
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={intelliSenseEnabled}
|
||||
onCheckedChange={handleIntelliSenseChange}
|
||||
>
|
||||
IntelliSense
|
||||
</DropdownMenuCheckboxItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{isSeparateQueryControlMode ? (
|
||||
<div className="hidden items-center gap-x-2 @4xl:flex">
|
||||
<SeparateQueryControls
|
||||
onSelectDatabase={onSelectDatabase}
|
||||
selectedDatabaseId={lastSelectedDb}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<QueryContextDropdown
|
||||
currentRole={currentRole}
|
||||
onSelectDatabase={onSelectDatabase}
|
||||
queryTarget={queryTarget}
|
||||
searchPath={searchPath}
|
||||
selectedDatabaseId={lastSelectedDb}
|
||||
/>
|
||||
)}
|
||||
<SqlEditorLimitSelector />
|
||||
{isManualSaveEnabled && <SqlSaveButton id={id} />}
|
||||
<SqlRunButton
|
||||
hasSelection={hasSelection}
|
||||
isDisabled={isDisabled || isExecuting}
|
||||
isExecuting={isExecuting}
|
||||
onClick={executeQuery}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type QueryContextDropdownProps = {
|
||||
currentRole: string
|
||||
onSelectDatabase: (databaseId: string) => void
|
||||
queryTarget: ReturnType<typeof useSqlQueryTarget>
|
||||
searchPath: string
|
||||
selectedDatabaseId: string
|
||||
}
|
||||
|
||||
type SeparateQueryControlsProps = {
|
||||
fullWidth?: boolean
|
||||
onSelectDatabase: (databaseId: string) => void
|
||||
selectedDatabaseId: string
|
||||
}
|
||||
|
||||
function SeparateQueryControls({
|
||||
fullWidth = false,
|
||||
onSelectDatabase,
|
||||
selectedDatabaseId,
|
||||
}: SeparateQueryControlsProps) {
|
||||
const controlClassName = fullWidth ? 'w-full [&>span]:w-full' : undefined
|
||||
|
||||
return (
|
||||
<>
|
||||
{IS_PLATFORM && (
|
||||
<DatabaseSelector
|
||||
selectedDatabaseId={selectedDatabaseId.length === 0 ? undefined : selectedDatabaseId}
|
||||
onSelectId={onSelectDatabase}
|
||||
className={controlClassName}
|
||||
align="end"
|
||||
/>
|
||||
)}
|
||||
<SqlEditorQueryTargetSelector className={controlClassName} align="end" />
|
||||
<SqlEditorSearchPathSelector className={controlClassName} align="end" />
|
||||
<RoleImpersonationPopover
|
||||
serviceRoleLabel="postgres"
|
||||
header="Run SQL query as a role"
|
||||
align="end"
|
||||
className={fullWidth ? 'w-full justify-start [&>span]:w-full' : undefined}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function QueryContextDropdown({
|
||||
currentRole,
|
||||
onSelectDatabase,
|
||||
queryTarget,
|
||||
searchPath,
|
||||
selectedDatabaseId,
|
||||
}: QueryContextDropdownProps) {
|
||||
const { ref } = useParams()
|
||||
const isPrimaryConnection = selectedDatabaseId.length === 0 || selectedDatabaseId === ref
|
||||
const selectedConnectionLabel = isPrimaryConnection ? 'Primary' : 'Replica'
|
||||
const activeContextLabels = [
|
||||
queryTarget === 'warehouse' ? 'Warehouse' : undefined,
|
||||
!isPrimaryConnection ? selectedConnectionLabel : undefined,
|
||||
searchPath !== DEFAULT_SQL_EDITOR_SEARCH_PATH ? searchPath : undefined,
|
||||
currentRole !== 'postgres' ? currentRole : undefined,
|
||||
].filter((label): label is string => label !== undefined)
|
||||
const contextSummary =
|
||||
activeContextLabels.length === 0
|
||||
? undefined
|
||||
: activeContextLabels.length === 1
|
||||
? activeContextLabels[0]
|
||||
: `${activeContextLabels.length} changes`
|
||||
|
||||
return (
|
||||
<Popover modal={false}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button type="button" size="tiny" variant="default" className="h-[26px] justify-start pr-3">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<span className="text-foreground-muted">Query</span>
|
||||
{contextSummary !== undefined && (
|
||||
<span className="hidden max-w-40 truncate text-foreground @2xl:inline">
|
||||
{contextSummary}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown className="shrink-0 text-muted" strokeWidth={1} size={12} />
|
||||
</div>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 p-0" align="end" side="bottom">
|
||||
<div className="border-b px-4 py-3">
|
||||
<p className="text-sm text-foreground">Query context</p>
|
||||
<p className="text-xs leading-snug text-foreground-light">
|
||||
These settings apply together when this query runs.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1 p-2">
|
||||
{IS_PLATFORM && (
|
||||
<DatabaseSelector
|
||||
selectedDatabaseId={selectedDatabaseId.length === 0 ? undefined : selectedDatabaseId}
|
||||
onSelectId={onSelectDatabase}
|
||||
className="w-full [&>span]:w-full"
|
||||
align="end"
|
||||
label="Connection"
|
||||
/>
|
||||
)}
|
||||
<SqlEditorQueryTargetSelector className="w-full [&>span]:w-full" align="end" />
|
||||
<SqlEditorSearchPathSelector className="w-full [&>span]:w-full" align="end" />
|
||||
<RoleImpersonationPopover
|
||||
serviceRoleLabel="postgres"
|
||||
header="Run SQL query as a role"
|
||||
align="end"
|
||||
className="w-full justify-start [&>span]:w-full"
|
||||
/>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { LOCAL_STORAGE_KEYS, useParams } from 'common'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import {
|
||||
Button,
|
||||
cn,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from 'ui'
|
||||
|
||||
import { isWarehouseProjectEnabled } from '@/components/interfaces/Database/Warehouse/warehouseDemoStore'
|
||||
import { useIsWarehouseEnabled } from '@/hooks/misc/useIsWarehouseEnabled'
|
||||
import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
|
||||
|
||||
export type SqlQueryTarget = 'postgres' | 'warehouse'
|
||||
|
||||
const QUERY_TARGET_OPTIONS: { value: SqlQueryTarget; label: string; description: string }[] = [
|
||||
{
|
||||
value: 'postgres',
|
||||
label: 'Postgres',
|
||||
description: 'Primary database connection',
|
||||
},
|
||||
{
|
||||
value: 'warehouse',
|
||||
label: 'Warehouse',
|
||||
description: 'Warehouse endpoint with 1:1 schema names',
|
||||
},
|
||||
]
|
||||
|
||||
type SqlEditorQueryTargetSelectorProps = {
|
||||
className?: string
|
||||
align?: 'start' | 'end'
|
||||
}
|
||||
|
||||
export function SqlEditorQueryTargetSelector({
|
||||
className,
|
||||
align = 'end',
|
||||
}: SqlEditorQueryTargetSelectorProps) {
|
||||
const { ref: projectRef } = useParams()
|
||||
const isWarehouseFeatureEnabled = useIsWarehouseEnabled()
|
||||
const warehouseEnabled = isWarehouseProjectEnabled(projectRef)
|
||||
|
||||
const [queryTarget, setQueryTarget] = useLocalStorageQuery<SqlQueryTarget>(
|
||||
LOCAL_STORAGE_KEYS.SQL_EDITOR_QUERY_TARGET(projectRef as string),
|
||||
'postgres'
|
||||
)
|
||||
|
||||
if (!isWarehouseFeatureEnabled || !warehouseEnabled) return null
|
||||
|
||||
const selectedOption =
|
||||
QUERY_TARGET_OPTIONS.find((option) => option.value === queryTarget) ?? QUERY_TARGET_OPTIONS[0]
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
size="tiny"
|
||||
variant="default"
|
||||
className={cn('h-[26px] justify-start gap-0 pr-3', className)}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<span className="text-foreground-muted">Database</span>
|
||||
<span className="max-w-32 truncate text-foreground">{selectedOption.label}</span>
|
||||
<ChevronDown className="shrink-0 text-muted" strokeWidth={1} size={12} />
|
||||
</div>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-72" align={align}>
|
||||
<DropdownMenuLabel className="flex flex-col gap-1">
|
||||
<span>Query database</span>
|
||||
<span className="text-xs font-normal leading-snug text-foreground-light">
|
||||
Choose whether this query runs against Postgres or the Warehouse endpoint.
|
||||
</span>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuRadioGroup
|
||||
value={selectedOption.value}
|
||||
onValueChange={(value) => setQueryTarget(value as SqlQueryTarget)}
|
||||
>
|
||||
{QUERY_TARGET_OPTIONS.map((option) => (
|
||||
<DropdownMenuRadioItem key={option.value} value={option.value}>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="truncate">{option.label}</span>
|
||||
<span className="truncate text-xs text-foreground-lighter">
|
||||
{option.description}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export function useSqlQueryTarget(): SqlQueryTarget {
|
||||
const { ref: projectRef } = useParams()
|
||||
const [queryTarget] = useLocalStorageQuery<SqlQueryTarget>(
|
||||
LOCAL_STORAGE_KEYS.SQL_EDITOR_QUERY_TARGET(projectRef as string),
|
||||
'postgres'
|
||||
)
|
||||
return queryTarget
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { LOCAL_STORAGE_KEYS, useParams } from 'common'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
cn,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from 'ui'
|
||||
|
||||
import { useSchemasQuery } from '@/data/database/schemas-query'
|
||||
import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
|
||||
type SearchPathOption = {
|
||||
value: string
|
||||
label: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
type SqlEditorSearchPathSelectorProps = {
|
||||
className?: string
|
||||
align?: 'start' | 'end'
|
||||
}
|
||||
|
||||
const SEARCH_PATH_OPTIONS: SearchPathOption[] = [
|
||||
{
|
||||
value: 'public',
|
||||
label: 'public',
|
||||
},
|
||||
{
|
||||
value: 'auth',
|
||||
label: 'auth',
|
||||
},
|
||||
{
|
||||
value: 'storage',
|
||||
label: 'storage',
|
||||
},
|
||||
]
|
||||
|
||||
export const DEFAULT_SQL_EDITOR_SEARCH_PATH = 'public'
|
||||
const COMMON_SCHEMA_NAMES = ['public', 'auth', 'storage']
|
||||
export const getSqlEditorSearchPathStorageKey = (ref: string) =>
|
||||
LOCAL_STORAGE_KEYS.SQL_EDITOR_SEARCH_PATH(ref)
|
||||
|
||||
function getSchemaDescription(schemaName: string) {
|
||||
if (schemaName.endsWith('_warehouse')) return 'Warehouse linked schema'
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function getSchemaSortPriority(schemaName: string) {
|
||||
const commonIndex = COMMON_SCHEMA_NAMES.indexOf(schemaName)
|
||||
if (commonIndex !== -1) return commonIndex
|
||||
if (schemaName.endsWith('_warehouse')) return COMMON_SCHEMA_NAMES.length
|
||||
|
||||
return COMMON_SCHEMA_NAMES.length + 1
|
||||
}
|
||||
|
||||
function getSearchPathOption(value: string, options: SearchPathOption[]) {
|
||||
return (
|
||||
options.find((option) => option.value === value) ?? {
|
||||
value,
|
||||
label: value,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function SqlEditorSearchPathSelector({
|
||||
className,
|
||||
align = 'end',
|
||||
}: SqlEditorSearchPathSelectorProps) {
|
||||
const { ref } = useParams()
|
||||
const [isShowingMoreSchemas, setIsShowingMoreSchemas] = useState(false)
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const { data: schemaData } = useSchemasQuery({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
})
|
||||
const [searchPath, setSearchPath] = useLocalStorageQuery(
|
||||
getSqlEditorSearchPathStorageKey(ref ?? 'unknown'),
|
||||
DEFAULT_SQL_EDITOR_SEARCH_PATH
|
||||
)
|
||||
const schemaOptions =
|
||||
schemaData !== undefined && schemaData.length > 0
|
||||
? schemaData
|
||||
.map((schema) => ({
|
||||
value: schema.name,
|
||||
label: schema.name,
|
||||
description: getSchemaDescription(schema.name),
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
const priority = getSchemaSortPriority(a.label) - getSchemaSortPriority(b.label)
|
||||
return priority !== 0 ? priority : a.label.localeCompare(b.label)
|
||||
})
|
||||
: SEARCH_PATH_OPTIONS
|
||||
const selectedOption = getSearchPathOption(searchPath, schemaOptions)
|
||||
const standardSchemaOptions = schemaOptions.filter(
|
||||
(option) => !option.value.endsWith('_warehouse')
|
||||
)
|
||||
const warehouseSchemaOptions = schemaOptions.filter((option) =>
|
||||
option.value.endsWith('_warehouse')
|
||||
)
|
||||
const visibleStandardSchemaOptions = standardSchemaOptions.filter(
|
||||
(option) =>
|
||||
isShowingMoreSchemas ||
|
||||
COMMON_SCHEMA_NAMES.includes(option.value) ||
|
||||
option.value === selectedOption.value
|
||||
)
|
||||
const hiddenStandardSchemaCount =
|
||||
standardSchemaOptions.length - visibleStandardSchemaOptions.length
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
size="tiny"
|
||||
variant="default"
|
||||
className={cn('h-[26px] justify-start gap-0 pr-3', className)}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<span className="text-foreground-muted">Search path</span>
|
||||
<span className="max-w-32 truncate text-foreground">{selectedOption.label}</span>
|
||||
<ChevronDown className="shrink-0 text-muted" strokeWidth={1} size={12} />
|
||||
</div>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-72" align={align}>
|
||||
<DropdownMenuLabel className="flex flex-col gap-1">
|
||||
<span>Search path</span>
|
||||
<span className="text-xs font-normal leading-snug text-foreground-light">
|
||||
Choose where your query looks first for unqualified table names.{' '}
|
||||
<a
|
||||
href="https://www.postgresql.org/docs/current/ddl-schemas.html#DDL-SCHEMAS-PATH"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-foreground underline decoration-foreground-lighter underline-offset-2 hover:decoration-foreground"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
</span>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuRadioGroup value={selectedOption.value} onValueChange={setSearchPath}>
|
||||
{standardSchemaOptions.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuLabel className="text-xs font-normal text-foreground-lighter">
|
||||
{warehouseSchemaOptions.length > 0 ? 'Postgres schemas' : 'Schemas'}
|
||||
</DropdownMenuLabel>
|
||||
{visibleStandardSchemaOptions.map((option) => (
|
||||
<DropdownMenuRadioItem key={option.value} value={option.value}>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="truncate">{option.label}</span>
|
||||
{option.description !== undefined && (
|
||||
<span className="truncate text-xs text-foreground-lighter">
|
||||
{option.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
{hiddenStandardSchemaCount > 0 && (
|
||||
<DropdownMenuItem
|
||||
className="text-foreground-light"
|
||||
onSelect={(event) => {
|
||||
event.preventDefault()
|
||||
setIsShowingMoreSchemas(true)
|
||||
}}
|
||||
>
|
||||
Show {hiddenStandardSchemaCount} more schema
|
||||
{hiddenStandardSchemaCount === 1 ? '' : 's'}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{warehouseSchemaOptions.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className="text-xs font-normal text-foreground-lighter">
|
||||
Warehouse schemas
|
||||
</DropdownMenuLabel>
|
||||
{warehouseSchemaOptions.map((option) => (
|
||||
<DropdownMenuRadioItem key={option.value} value={option.value}>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="truncate">{option.label}</span>
|
||||
{option.description !== undefined && (
|
||||
<span className="truncate text-xs text-foreground-lighter">
|
||||
{option.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from 'ui'
|
||||
import { proxy, useSnapshot } from 'valtio'
|
||||
|
||||
import type { SqlQueryTarget } from './SqlEditorQueryTargetSelector'
|
||||
|
||||
export type SqlWarehouseHealthPreset = 'healthy' | 'catching_up' | 'degraded' | 'error'
|
||||
export type SqlEditorQueryControlMode = 'compact' | 'separate'
|
||||
|
||||
export type SqlWarehouseResultSource =
|
||||
| { type: 'query_target' }
|
||||
| { type: 'search_path'; schema: string }
|
||||
| { type: 'query_reference' }
|
||||
|
||||
const SQL_WAREHOUSE_SCHEMA_PATTERN = /\b[a-zA-Z_][\w$]*_warehouse\s*\./i
|
||||
|
||||
const SQL_WAREHOUSE_HEALTH_LABELS: Record<SqlWarehouseHealthPreset, string> = {
|
||||
healthy: '',
|
||||
catching_up: 'Catching up',
|
||||
degraded: 'Degraded',
|
||||
error: 'Error',
|
||||
}
|
||||
|
||||
const SQL_WAREHOUSE_HEALTH_TOOLTIPS: Record<SqlWarehouseHealthPreset, string> = {
|
||||
healthy: 'Warehouse replication is caught up with Postgres.',
|
||||
catching_up:
|
||||
'Warehouse replication is catching up. Recent writes may not appear in linked tables yet.',
|
||||
degraded: 'Warehouse replication is degraded. Query results may be stale.',
|
||||
error: 'Warehouse replication encountered an error. Check replication logs.',
|
||||
}
|
||||
|
||||
const SQL_WAREHOUSE_HEALTH_CLASS_NAMES: Record<SqlWarehouseHealthPreset, string> = {
|
||||
healthy: 'text-foreground-light hover:text-foreground',
|
||||
catching_up: 'text-warning hover:text-warning-600',
|
||||
degraded: 'text-destructive hover:text-destructive-600',
|
||||
error: 'text-destructive hover:text-destructive-600',
|
||||
}
|
||||
|
||||
export const sqlEditorWarehouseDemoStore = proxy<{
|
||||
health: SqlWarehouseHealthPreset
|
||||
queryControlMode: SqlEditorQueryControlMode
|
||||
}>({
|
||||
health: 'healthy',
|
||||
queryControlMode: 'separate',
|
||||
})
|
||||
|
||||
export function setSqlEditorWarehouseHealth(health: SqlWarehouseHealthPreset) {
|
||||
sqlEditorWarehouseDemoStore.health = health
|
||||
}
|
||||
|
||||
export function setSqlEditorQueryControlMode(mode: SqlEditorQueryControlMode) {
|
||||
sqlEditorWarehouseDemoStore.queryControlMode = mode
|
||||
}
|
||||
|
||||
export function resolveSqlWarehouseResultSource(
|
||||
sql: string,
|
||||
searchPath: string,
|
||||
queryTarget: SqlQueryTarget = 'postgres'
|
||||
): SqlWarehouseResultSource | undefined {
|
||||
if (queryTarget === 'warehouse') {
|
||||
return { type: 'query_target' }
|
||||
}
|
||||
|
||||
if (searchPath.endsWith('_warehouse')) {
|
||||
return { type: 'search_path', schema: searchPath }
|
||||
}
|
||||
|
||||
if (SQL_WAREHOUSE_SCHEMA_PATTERN.test(sql)) {
|
||||
return { type: 'query_reference' }
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function getWarehouseResultSourceTooltip(source: SqlWarehouseResultSource) {
|
||||
if (source.type === 'query_target') {
|
||||
return 'Results are served from the Warehouse endpoint. Schemas and table names match Postgres.'
|
||||
}
|
||||
|
||||
if (source.type === 'search_path') {
|
||||
return `Results are served from Warehouse because the search path starts with ${source.schema}.`
|
||||
}
|
||||
|
||||
return 'Results include a linked Warehouse table referenced directly in the SQL.'
|
||||
}
|
||||
|
||||
export function SqlWarehouseResultStatus({ source }: { source: SqlWarehouseResultSource }) {
|
||||
const snap = useSnapshot(sqlEditorWarehouseDemoStore)
|
||||
const health = snap.health as SqlWarehouseHealthPreset
|
||||
const shouldShowHealth = health !== 'healthy'
|
||||
|
||||
return (
|
||||
<span className="inline-flex min-w-0 items-center gap-1.5 truncate text-xs">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="group inline-flex cursor-default text-foreground-light transition-colors hover:text-foreground">
|
||||
<span className="border-b border-dotted border-current/40 transition-colors group-hover:border-current/70">
|
||||
Warehouse
|
||||
</span>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-72">
|
||||
{getWarehouseResultSourceTooltip(source)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{shouldShowHealth && (
|
||||
<>
|
||||
<span className="shrink-0 text-foreground-muted">·</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className={`group inline-flex cursor-default transition-colors ${SQL_WAREHOUSE_HEALTH_CLASS_NAMES[health]}`}
|
||||
>
|
||||
<span className="border-b border-dotted border-current/40 transition-colors group-hover:border-current/70">
|
||||
{SQL_WAREHOUSE_HEALTH_LABELS[health]}
|
||||
</span>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-72">
|
||||
{SQL_WAREHOUSE_HEALTH_TOOLTIPS[health]}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import { LOCAL_STORAGE_KEYS, useParams } from 'common'
|
||||
import { cn } from 'ui'
|
||||
|
||||
import { isWarehouseProjectEnabled } from '@/components/interfaces/Database/Warehouse/warehouseDemoStore'
|
||||
import { useIsWarehouseEnabled } from '@/hooks/misc/useIsWarehouseEnabled'
|
||||
import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
|
||||
|
||||
export type SqlQueryTarget = 'postgres' | 'warehouse'
|
||||
|
||||
interface SqlWarehouseTargetToggleProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function SqlWarehouseTargetToggle({ className }: SqlWarehouseTargetToggleProps) {
|
||||
const { ref: projectRef } = useParams()
|
||||
const isWarehouseFeatureEnabled = useIsWarehouseEnabled()
|
||||
const warehouseEnabled = isWarehouseProjectEnabled(projectRef)
|
||||
|
||||
const [queryTarget, setQueryTarget] = useLocalStorageQuery<SqlQueryTarget>(
|
||||
LOCAL_STORAGE_KEYS.SQL_EDITOR_QUERY_TARGET(projectRef as string),
|
||||
'postgres'
|
||||
)
|
||||
|
||||
if (!isWarehouseFeatureEnabled || !warehouseEnabled) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center rounded-md border border-overlay bg-surface-200 p-0.5 text-xs',
|
||||
className
|
||||
)}
|
||||
role="group"
|
||||
aria-label="SQL query target"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'rounded px-2 py-1 transition-colors',
|
||||
queryTarget === 'postgres'
|
||||
? 'bg-surface-100 text-foreground shadow-sm'
|
||||
: 'text-foreground-light hover:text-foreground'
|
||||
)}
|
||||
onClick={() => setQueryTarget('postgres')}
|
||||
>
|
||||
Postgres
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'rounded px-2 py-1 transition-colors',
|
||||
queryTarget === 'warehouse'
|
||||
? 'bg-surface-100 text-foreground shadow-sm'
|
||||
: 'text-foreground-light hover:text-foreground'
|
||||
)}
|
||||
onClick={() => setQueryTarget('warehouse')}
|
||||
>
|
||||
Warehouse
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function useSqlQueryTarget(): SqlQueryTarget {
|
||||
const { ref: projectRef } = useParams()
|
||||
const [queryTarget] = useLocalStorageQuery<SqlQueryTarget>(
|
||||
LOCAL_STORAGE_KEYS.SQL_EDITOR_QUERY_TARGET(projectRef as string),
|
||||
'postgres'
|
||||
)
|
||||
return queryTarget
|
||||
}
|
||||
@@ -8,13 +8,8 @@ import {
|
||||
} from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
|
||||
import { IS_PLATFORM } from '@/lib/constants'
|
||||
import { useTrack } from '@/lib/telemetry/track'
|
||||
import { hasUnsavedChanges } from '@/state/sql-editor/sql-editor-lifecycle'
|
||||
import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor/sql-editor-state'
|
||||
|
||||
export type AutosaveStatusProps = { id: string }
|
||||
|
||||
export const AutosaveStatus = ({ id }: AutosaveStatusProps) => {
|
||||
const snapV2 = useSqlEditorV2StateSnapshot()
|
||||
export const AutosaveStatus = () => {
|
||||
const track = useTrack()
|
||||
const isManualSaveEnabled = useIsSqlEditorManualSaveEnabled()
|
||||
const { selectFeaturePreview } = useFeaturePreviewModal()
|
||||
@@ -27,21 +22,7 @@ export const AutosaveStatus = ({ id }: AutosaveStatusProps) => {
|
||||
const sqlEditorManualSaveFlag = useFlag('sqlEditorManualSave')
|
||||
const canEnableManualSave = IS_PLATFORM && sqlEditorManualSaveFlag
|
||||
|
||||
if (isManualSaveEnabled) {
|
||||
const snippet = snapV2.snippets[id]
|
||||
// A snippet only enters the store on its first edit, so a snippet that
|
||||
// isn't in the store yet is a fresh, blank, untouched "new query" tab —
|
||||
// there's nothing to report a save status for.
|
||||
if (snippet === undefined) return null
|
||||
|
||||
const unsavedChanges = hasUnsavedChanges(snippet.snippet.status)
|
||||
|
||||
return (
|
||||
<span className="text-xs text-foreground-lighter">
|
||||
{unsavedChanges ? 'Unsaved edits' : 'Saved'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (isManualSaveEnabled) return null
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { Button, KeyboardShortcut } from 'ui'
|
||||
import { CirclePlay, Loader2 } from 'lucide-react'
|
||||
import { Button } from 'ui'
|
||||
|
||||
import { ShortcutTooltip } from '@/components/ui/ShortcutTooltip'
|
||||
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
|
||||
|
||||
interface SqlRunButtonProps {
|
||||
isDisabled?: boolean
|
||||
@@ -17,22 +20,27 @@ export const SqlRunButton = ({
|
||||
onClick,
|
||||
}: SqlRunButtonProps) => {
|
||||
return (
|
||||
<Button
|
||||
onClick={onClick}
|
||||
disabled={isDisabled}
|
||||
variant="primary"
|
||||
size="tiny"
|
||||
data-testid="sql-run-button"
|
||||
iconRight={
|
||||
isExecuting ? (
|
||||
<Loader2 className="animate-spin" size={10} strokeWidth={1.5} />
|
||||
) : (
|
||||
<KeyboardShortcut keys={['Meta', 'Enter']} variant="inline" />
|
||||
)
|
||||
}
|
||||
className={className}
|
||||
<ShortcutTooltip
|
||||
shortcutId={SHORTCUT_IDS.SQL_EDITOR_RUN}
|
||||
label={hasSelection ? 'Run selected' : undefined}
|
||||
>
|
||||
{hasSelection ? 'Run selected' : 'Run'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onClick}
|
||||
disabled={isDisabled}
|
||||
variant="primary"
|
||||
size="tiny"
|
||||
data-testid="sql-run-button"
|
||||
icon={
|
||||
isExecuting ? (
|
||||
<Loader2 className="animate-spin" size={10} strokeWidth={1.5} />
|
||||
) : (
|
||||
<CirclePlay size={12} strokeWidth={1.5} />
|
||||
)
|
||||
}
|
||||
className={className}
|
||||
>
|
||||
{hasSelection ? 'Run selected' : 'Run'}
|
||||
</Button>
|
||||
</ShortcutTooltip>
|
||||
)
|
||||
}
|
||||
@@ -1,16 +1,17 @@
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { Button, KeyboardShortcut } from 'ui'
|
||||
import { Button, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
|
||||
|
||||
import { ShortcutTooltip } from '@/components/ui/ShortcutTooltip'
|
||||
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
|
||||
import { hasUnsavedChanges, isSaving } from '@/state/sql-editor/sql-editor-lifecycle'
|
||||
import { useSqlEditorSaveCoordinator } from '@/state/sql-editor/sql-editor-save-coordinator'
|
||||
import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor/sql-editor-state'
|
||||
|
||||
interface SqlSaveButtonProps {
|
||||
id: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const SqlSaveButton = ({ id, className }: SqlSaveButtonProps) => {
|
||||
export const SqlSaveButton = ({ id }: SqlSaveButtonProps) => {
|
||||
const snapV2 = useSqlEditorV2StateSnapshot()
|
||||
const { requestSave } = useSqlEditorSaveCoordinator()
|
||||
|
||||
@@ -18,23 +19,31 @@ export const SqlSaveButton = ({ id, className }: SqlSaveButtonProps) => {
|
||||
const saving = isSaving(status)
|
||||
const isDirty = hasUnsavedChanges(status) && !saving
|
||||
|
||||
return (
|
||||
const button = (
|
||||
<Button
|
||||
onClick={() => requestSave(id)}
|
||||
disabled={!isDirty}
|
||||
variant="default"
|
||||
size="tiny"
|
||||
data-testid="sql-save-button"
|
||||
iconRight={
|
||||
saving ? (
|
||||
<Loader2 className="animate-spin" size={10} strokeWidth={1.5} />
|
||||
) : (
|
||||
<KeyboardShortcut keys={['Meta', 's']} variant="inline" />
|
||||
)
|
||||
}
|
||||
className={className}
|
||||
icon={saving ? <Loader2 className="animate-spin" size={12} strokeWidth={1.5} /> : undefined}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
)
|
||||
|
||||
if (isDirty) {
|
||||
return <ShortcutTooltip shortcutId={SHORTCUT_IDS.SQL_EDITOR_SAVE}>{button}</ShortcutTooltip>
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex">{button}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{saving ? 'Saving changes...' : 'All changes saved'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import {
|
||||
Button,
|
||||
cn,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
} from 'ui'
|
||||
|
||||
import { ROWS_PER_PAGE_OPTIONS } from '@/components/interfaces/SQLEditor/SQLEditor.constants'
|
||||
import { useSqlEditorSessionSnapshot } from '@/state/sql-editor/sql-editor-session-state'
|
||||
|
||||
interface SqlEditorLimitSelectorProps {
|
||||
variant?: 'regular' | 'connected-on-right' | 'connected-on-both'
|
||||
}
|
||||
|
||||
export function SqlEditorLimitSelector({ variant = 'regular' }: SqlEditorLimitSelectorProps) {
|
||||
const sessionSnap = useSqlEditorSessionSnapshot()
|
||||
const selectedLabel =
|
||||
ROWS_PER_PAGE_OPTIONS.find((opt) => opt.value === sessionSnap.limit)?.label ?? '100 rows'
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
size="tiny"
|
||||
variant="default"
|
||||
className={cn(
|
||||
'h-[26px] justify-start gap-0 pr-3',
|
||||
variant === 'connected-on-right' && 'rounded-r-none border-r-0',
|
||||
variant === 'connected-on-both' && 'rounded-none border-x-0'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-foreground-muted">Limit</span>
|
||||
<span>{selectedLabel}</span>
|
||||
<ChevronDown className="text-muted" strokeWidth={1} size={12} />
|
||||
</div>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-40" align="end">
|
||||
<DropdownMenuRadioGroup
|
||||
value={sessionSnap.limit.toString()}
|
||||
onValueChange={(val) => sessionSnap.setLimit(Number(val))}
|
||||
>
|
||||
{ROWS_PER_PAGE_OPTIONS.map((option) => (
|
||||
<DropdownMenuRadioItem key={option.label} value={option.value.toString()}>
|
||||
{option.label}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from 'ui'
|
||||
|
||||
interface SqlResultsSummaryProps {
|
||||
rowCount: number
|
||||
autoLimit?: number
|
||||
}
|
||||
|
||||
function formatRowCount(rowCount: number) {
|
||||
return `${rowCount.toLocaleString()} row${rowCount === 1 ? '' : 's'}`
|
||||
}
|
||||
|
||||
export function SqlResultsSummary({ rowCount, autoLimit }: SqlResultsSummaryProps) {
|
||||
const hasReachedLimit = autoLimit !== undefined && rowCount >= autoLimit
|
||||
|
||||
return (
|
||||
<p className="flex min-w-0 items-center gap-1.5 truncate text-xs text-foreground-lighter">
|
||||
{hasReachedLimit && (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="group inline-flex cursor-default text-warning transition-colors hover:text-warning-600">
|
||||
<span className="border-b border-dotted border-current/40 transition-colors group-hover:border-current/70">
|
||||
Limited
|
||||
</span>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
<div className="flex flex-col gap-y-1">
|
||||
<span>
|
||||
Results are limited to preserve browser performance when a query returns many
|
||||
rows.
|
||||
</span>
|
||||
<span className="text-foreground-light">
|
||||
Change or remove this limit from the dropdown near Run.
|
||||
</span>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<span className="shrink-0 text-foreground-muted">·</span>
|
||||
</>
|
||||
)}
|
||||
<span className="truncate text-foreground">
|
||||
{formatRowCount(rowCount)}
|
||||
{hasReachedLimit && (
|
||||
<span className="ml-1 text-foreground-lighter">(limit {autoLimit.toLocaleString()})</span>
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
import { Hotkey } from '@tanstack/react-hotkeys'
|
||||
import { LOCAL_STORAGE_KEYS, useParams } from 'common'
|
||||
import { AlignLeft, Check, Heart, Keyboard, MoreVertical } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Button,
|
||||
cn,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
KeyboardShortcut,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from 'ui'
|
||||
|
||||
import { SqlWarehouseTargetToggle } from '../SqlWarehouseTargetToggle'
|
||||
import { AutosaveStatus } from './AutosaveStatus'
|
||||
import { SqlRunButton } from './RunButton'
|
||||
import { SqlSaveButton } from './SaveButton'
|
||||
import SavingIndicator from './SavingIndicator'
|
||||
import { useIsSqlEditorManualSaveEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
|
||||
import { RoleImpersonationPopover } from '@/components/interfaces/RoleImpersonationSelector/RoleImpersonationPopover'
|
||||
import { DatabaseSelector } from '@/components/ui/DatabaseSelector'
|
||||
import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
|
||||
import { IS_PLATFORM } from '@/lib/constants'
|
||||
import { hotkeyToKeys } from '@/state/shortcuts/formatShortcut'
|
||||
import { SHORTCUT_DEFINITIONS, SHORTCUT_IDS } from '@/state/shortcuts/registry'
|
||||
import { useSqlEditorSessionSnapshot } from '@/state/sql-editor/sql-editor-session-state'
|
||||
import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor/sql-editor-state'
|
||||
|
||||
export type UtilityActionsProps = {
|
||||
id: string
|
||||
isExecuting?: boolean
|
||||
isDisabled?: boolean
|
||||
hasSelection?: boolean
|
||||
prettifyQuery: () => void
|
||||
executeQuery: () => void
|
||||
}
|
||||
|
||||
export const UtilityActions = ({
|
||||
id,
|
||||
isExecuting = false,
|
||||
isDisabled = false,
|
||||
hasSelection = false,
|
||||
prettifyQuery,
|
||||
executeQuery,
|
||||
}: UtilityActionsProps) => {
|
||||
const { ref } = useParams()
|
||||
const snapV2 = useSqlEditorV2StateSnapshot()
|
||||
const sessionSnap = useSqlEditorSessionSnapshot()
|
||||
const isManualSaveEnabled = useIsSqlEditorManualSaveEnabled()
|
||||
|
||||
const [isAiOpen] = useLocalStorageQuery(LOCAL_STORAGE_KEYS.SQL_EDITOR_AI_OPEN, true)
|
||||
const [intellisenseEnabled, setIntellisenseEnabled] = useLocalStorageQuery(
|
||||
LOCAL_STORAGE_KEYS.SQL_EDITOR_INTELLISENSE,
|
||||
true
|
||||
)
|
||||
const [lastSelectedDb, setLastSelectedDb] = useLocalStorageQuery(
|
||||
LOCAL_STORAGE_KEYS.SQL_EDITOR_LAST_SELECTED_DB(ref as string),
|
||||
''
|
||||
)
|
||||
|
||||
const snippet = snapV2.snippets[id]
|
||||
const isFavorite = snippet !== undefined ? snippet.snippet.favorite : false
|
||||
|
||||
const hotkeySequnece: Hotkey | undefined =
|
||||
SHORTCUT_DEFINITIONS[SHORTCUT_IDS.SQL_EDITOR_FORMAT].sequence[0]
|
||||
const formatKeys = hotkeySequnece ? hotkeyToKeys(hotkeySequnece) : undefined
|
||||
|
||||
const toggleIntellisense = () => {
|
||||
setIntellisenseEnabled(!intellisenseEnabled)
|
||||
toast.success(
|
||||
`Successfully ${intellisenseEnabled ? 'disabled' : 'enabled'} intellisense. ${intellisenseEnabled ? 'Please refresh your browser for changes to take place.' : ''}`
|
||||
)
|
||||
}
|
||||
|
||||
const addFavorite = () => snapV2.addFavorite(id)
|
||||
|
||||
const removeFavorite = () => snapV2.removeFavorite(id)
|
||||
|
||||
const onSelectDatabase = (databaseId: string) => {
|
||||
sessionSnap.resetResults(id)
|
||||
setLastSelectedDb(databaseId)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="inline-flex items-center justify-end gap-x-2">
|
||||
<AutosaveStatus id={id} />
|
||||
{/* SavingIndicator reports auto-save progress (spinner/checkmark). In manual
|
||||
mode AutosaveStatus + the Save button own the status, so hide it there. */}
|
||||
{IS_PLATFORM && !isManualSaveEnabled && <SavingIndicator id={id} />}
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
data-testid="sql-editor-utility-actions"
|
||||
variant="default"
|
||||
className={cn('px-1', isAiOpen ? 'block 2xl:hidden' : 'hidden')}
|
||||
icon={<MoreVertical className="text-foreground-light" />}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-48">
|
||||
<DropdownMenuItem className="justify-between" onClick={toggleIntellisense}>
|
||||
<span className="flex items-center gap-x-2">
|
||||
<Keyboard size={14} className="text-foreground-light" />
|
||||
Intellisense enabled
|
||||
</span>
|
||||
{intellisenseEnabled && <Check className="text-brand" size={16} />}
|
||||
</DropdownMenuItem>
|
||||
{IS_PLATFORM && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="gap-x-2"
|
||||
onClick={() => {
|
||||
if (isFavorite) removeFavorite()
|
||||
else addFavorite()
|
||||
}}
|
||||
>
|
||||
<Heart
|
||||
size={14}
|
||||
strokeWidth={2}
|
||||
className={
|
||||
isFavorite ? 'fill-brand stroke-none' : 'fill-none stroke-foreground-light'
|
||||
}
|
||||
/>
|
||||
{isFavorite ? 'Remove from' : 'Add to'} favorites
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem className="justify-between" onClick={prettifyQuery}>
|
||||
<span className="flex items-center gap-x-2">
|
||||
<AlignLeft size={14} strokeWidth={2} className="text-foreground-light" />
|
||||
Prettify SQL
|
||||
</span>
|
||||
{formatKeys && <KeyboardShortcut keys={formatKeys} />}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<div className={cn('items-center gap-x-2', isAiOpen ? 'hidden 2xl:flex' : 'flex')}>
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
className="px-1"
|
||||
icon={<Keyboard className="text-foreground-light" />}
|
||||
aria-label="Enable Intellisense"
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Enable Intellisense</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent className="w-48">
|
||||
<DropdownMenuItem className="justify-between" onClick={toggleIntellisense}>
|
||||
Intellisense enabled
|
||||
{intellisenseEnabled && <Check className="text-brand" size={16} />}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{IS_PLATFORM && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{isFavorite ? (
|
||||
<Button
|
||||
variant="text"
|
||||
size="tiny"
|
||||
onClick={removeFavorite}
|
||||
className="px-1"
|
||||
icon={<Heart className="fill-brand stroke-none" />}
|
||||
aria-label="Remove from favorites"
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
variant="text"
|
||||
size="tiny"
|
||||
onClick={addFavorite}
|
||||
className="px-1"
|
||||
icon={<Heart className="fill-none stroke-foreground-light" />}
|
||||
aria-label="Add to favorites"
|
||||
/>
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{isFavorite ? 'Remove from' : 'Add to'} favorites
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
onClick={prettifyQuery}
|
||||
className="px-1"
|
||||
icon={<AlignLeft strokeWidth={2} className="text-foreground-light" />}
|
||||
aria-label="Prettify SQL"
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="p-1 pl-2.5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span>Prettify SQL</span>
|
||||
{formatKeys && <KeyboardShortcut keys={formatKeys} />}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-x-2">
|
||||
<SqlWarehouseTargetToggle />
|
||||
<div className="flex items-center">
|
||||
{IS_PLATFORM && (
|
||||
<DatabaseSelector
|
||||
selectedDatabaseId={lastSelectedDb.length === 0 ? undefined : lastSelectedDb}
|
||||
variant="connected-on-right"
|
||||
onSelectId={onSelectDatabase}
|
||||
/>
|
||||
)}
|
||||
<RoleImpersonationPopover
|
||||
serviceRoleLabel="postgres"
|
||||
header="Run SQL query as a role"
|
||||
variant={IS_PLATFORM ? 'connected-on-left' : 'regular'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
{isManualSaveEnabled && <SqlSaveButton id={id} className="rounded-r-none" />}
|
||||
<SqlRunButton
|
||||
hasSelection={hasSelection}
|
||||
isDisabled={isDisabled || isExecuting}
|
||||
isExecuting={isExecuting}
|
||||
className={isManualSaveEnabled ? 'rounded-l-none' : undefined}
|
||||
onClick={executeQuery}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,8 +2,9 @@ import { useParams } from 'common'
|
||||
import { toast } from 'sonner'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from 'ui'
|
||||
|
||||
import { SqlWarehouseResultStatus } from '../SqlEditorWarehouseDemo'
|
||||
import { ChartConfig } from './ChartConfig'
|
||||
import { UtilityActions } from './UtilityActions'
|
||||
import { SqlResultsSummary } from './SqlResultsSummary'
|
||||
import { UtilityTabExplain } from './UtilityTabExplain'
|
||||
import { UtilityTabResults } from './UtilityTabResults'
|
||||
import { DownloadResultsButton } from '@/components/ui/DownloadResultsButton'
|
||||
@@ -19,9 +20,6 @@ export type UtilityPanelProps = {
|
||||
isExplainExecuting?: boolean
|
||||
isDebugging?: boolean
|
||||
isDisabled?: boolean
|
||||
hasSelection: boolean
|
||||
prettifyQuery: () => void
|
||||
executeQuery: () => void
|
||||
executeExplainQuery: () => void
|
||||
showExplainTab?: boolean
|
||||
onDebug: () => void
|
||||
@@ -45,9 +43,6 @@ export const UtilityPanel = ({
|
||||
isExplainExecuting,
|
||||
isDebugging,
|
||||
isDisabled,
|
||||
hasSelection,
|
||||
prettifyQuery,
|
||||
executeQuery,
|
||||
executeExplainQuery,
|
||||
showExplainTab = true,
|
||||
onDebug,
|
||||
@@ -62,6 +57,11 @@ export const UtilityPanel = ({
|
||||
|
||||
const snippet = snapV2.snippets[id]?.snippet
|
||||
const result = sessionSnap.results[id]?.[0]
|
||||
const showResultsSummary =
|
||||
(activeTab === 'results' || activeTab === 'chart') &&
|
||||
result?.rows !== undefined &&
|
||||
!isExecuting &&
|
||||
!result.error
|
||||
|
||||
const handleTabChange = (tab: string) => {
|
||||
// When switching to the explain tab, trigger the explain query
|
||||
@@ -131,8 +131,8 @@ export const UtilityPanel = ({
|
||||
|
||||
return (
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full h-full flex flex-col">
|
||||
<TabsList className="flex justify-between gap-2 px-4 overflow-x-auto min-h-[42px]">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex min-h-[42px] shrink-0 items-center justify-between gap-2 overflow-x-auto border-b pl-3 pr-2">
|
||||
<TabsList className="h-auto gap-4 border-b-0 p-0">
|
||||
<TabsTrigger className="py-3 text-xs" value="results">
|
||||
<span className="translate-y-px">Results</span>
|
||||
</TabsTrigger>
|
||||
@@ -144,12 +144,23 @@ export const UtilityPanel = ({
|
||||
<TabsTrigger className="py-3 text-xs" value="chart">
|
||||
<span className="translate-y-px">Chart</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="flex min-w-0 items-center gap-x-3">
|
||||
{showResultsSummary && (
|
||||
<>
|
||||
{result.warehouseResultSource && (
|
||||
<SqlWarehouseResultStatus source={result.warehouseResultSource} />
|
||||
)}
|
||||
<SqlResultsSummary rowCount={result.rows.length} autoLimit={result.autoLimit} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{result?.rows && (
|
||||
<DownloadResultsButton
|
||||
variant="text"
|
||||
results={result.rows as any[]}
|
||||
fileName={`Supabase Snippet ${snippet?.name ?? 'Results'}`}
|
||||
align="end"
|
||||
onDownloadAsCSV={() => track('sql_editor_result_download_csv_clicked')}
|
||||
onCopyAsMarkdown={() => track('sql_editor_result_copy_markdown_clicked')}
|
||||
onCopyAsJSON={() => track('sql_editor_result_copy_json_clicked')}
|
||||
@@ -157,16 +168,7 @@ export const UtilityPanel = ({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<UtilityActions
|
||||
id={id}
|
||||
isExecuting={isExecuting}
|
||||
isDisabled={isDisabled}
|
||||
hasSelection={hasSelection}
|
||||
prettifyQuery={prettifyQuery}
|
||||
executeQuery={executeQuery}
|
||||
/>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent asChild value="results" className="mt-0 grow">
|
||||
<UtilityTabResults
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
|
||||
import { ButtonTooltip } from '../ui/ButtonTooltip'
|
||||
import { useFeaturePreviewModal } from './App/FeaturePreview/FeaturePreviewContext'
|
||||
import { SqlEditorWarehouseDemoMenu } from './UserDropdown/SqlEditorWarehouseDemoMenu'
|
||||
import { TimezoneDropdown } from './UserDropdown/TimezoneDropdown'
|
||||
import { ProfileImage } from '@/components/ui/ProfileImage'
|
||||
import { UpgradePlanButton } from '@/components/ui/UpgradePlanButton'
|
||||
@@ -136,6 +137,7 @@ export function UserDropdown({
|
||||
Changelog
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<SqlEditorWarehouseDemoMenu />
|
||||
<DropdownMenuSeparator />
|
||||
</DropdownMenuGroup>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Database } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
} from 'ui'
|
||||
import { useSnapshot } from 'valtio'
|
||||
|
||||
import {
|
||||
setSqlEditorQueryControlMode,
|
||||
setSqlEditorWarehouseHealth,
|
||||
sqlEditorWarehouseDemoStore,
|
||||
type SqlEditorQueryControlMode,
|
||||
type SqlWarehouseHealthPreset,
|
||||
} from '@/components/interfaces/SQLEditor/SqlEditorWarehouseDemo'
|
||||
|
||||
const WAREHOUSE_HEALTH_PRESETS: { preset: SqlWarehouseHealthPreset; label: string }[] = [
|
||||
{ preset: 'healthy', label: 'Live' },
|
||||
{ preset: 'catching_up', label: 'Catching up' },
|
||||
{ preset: 'degraded', label: 'Degraded' },
|
||||
{ preset: 'error', label: 'Error' },
|
||||
]
|
||||
|
||||
const QUERY_CONTROL_MODES: { mode: SqlEditorQueryControlMode; label: string }[] = [
|
||||
{ mode: 'compact', label: 'Compact Query dropdown' },
|
||||
{ mode: 'separate', label: 'Separate controls' },
|
||||
]
|
||||
|
||||
export function SqlEditorWarehouseDemoMenu() {
|
||||
const snap = useSnapshot(sqlEditorWarehouseDemoStore)
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger className="flex gap-2">
|
||||
<Database size={14} strokeWidth={1.5} className="text-foreground-lighter" />
|
||||
SQL Warehouse demo
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="w-52">
|
||||
<DropdownMenuLabel className="text-xs text-foreground-lighter">
|
||||
Results health
|
||||
</DropdownMenuLabel>
|
||||
{WAREHOUSE_HEALTH_PRESETS.map(({ preset, label }) => (
|
||||
<DropdownMenuItem
|
||||
key={preset}
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
setSqlEditorWarehouseHealth(preset)
|
||||
toast.message(`SQL Warehouse demo: ${label}`)
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className="text-xs text-foreground-lighter">
|
||||
Query control
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuRadioGroup
|
||||
value={snap.queryControlMode}
|
||||
onValueChange={(value) => {
|
||||
if (value === 'compact' || value === 'separate') {
|
||||
setSqlEditorQueryControlMode(value)
|
||||
toast.message(
|
||||
`SQL Warehouse demo: ${
|
||||
value === 'compact' ? 'Compact Query dropdown' : 'Separate controls'
|
||||
}`
|
||||
)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{QUERY_CONTROL_MODES.map(({ mode, label }) => (
|
||||
<DropdownMenuRadioItem key={mode} value={mode} className="cursor-pointer">
|
||||
{label}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { useDebounce } from '@uidotdev/usehooks'
|
||||
import { LOCAL_STORAGE_KEYS, useParams } from 'common'
|
||||
import { FilePlus, FolderPlus, Plus, X } from 'lucide-react'
|
||||
import { ChevronDown, FolderPlus, List, Plus, X } from 'lucide-react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
@@ -45,7 +45,6 @@ export const SQLEditorMenu = () => {
|
||||
'inserted_at'
|
||||
)
|
||||
|
||||
const appState = getAppStateSnapshot()
|
||||
const debouncedSearch = useDebounce(search, 500)
|
||||
|
||||
const { can: canCreateSQLSnippet } = useAsyncCheckPermissions(
|
||||
@@ -80,95 +79,102 @@ export const SQLEditorMenu = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const openRunningQueries = () => {
|
||||
getAppStateSnapshot().setOnGoingQueriesPanelOpen(true)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setShowSearch(debouncedSearch.length > 0)
|
||||
}, [debouncedSearch])
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col justify-between">
|
||||
<div className="flex flex-col gap-y-4 grow">
|
||||
<div className="mt-4 mx-4 flex items-center justify-between gap-x-2">
|
||||
<InnerSideBarFilters className="w-full p-0 gap-0">
|
||||
<InnerSideBarFilterSearchInput
|
||||
name="search-queries"
|
||||
placeholder="Search queries..."
|
||||
aria-labelledby="Search queries"
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
setSearch(value)
|
||||
if (value.length === 0) setShowSearch(false)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.code === 'Escape') {
|
||||
setSearch('')
|
||||
setShowSearch(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{showSearch ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
className="absolute right-1 top-[.4rem] md:top-[.3rem] transition-colors text-foreground-light hover:text-foreground"
|
||||
onClick={() => {
|
||||
setSearch('')
|
||||
setShowSearch(false)
|
||||
}}
|
||||
>
|
||||
<X size={18} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Clear search</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<InnerSideBarFilterSortDropdown
|
||||
value={sort}
|
||||
onValueChange={(value: any) => setSort(value)}
|
||||
>
|
||||
<InnerSideBarFilterSortDropdownItem key="name" value="name">
|
||||
Alphabetical
|
||||
</InnerSideBarFilterSortDropdownItem>
|
||||
<InnerSideBarFilterSortDropdownItem key="inserted_at" value="inserted_at">
|
||||
Created At
|
||||
</InnerSideBarFilterSortDropdownItem>
|
||||
</InnerSideBarFilterSortDropdown>
|
||||
)}
|
||||
</InnerSideBarFilterSearchInput>
|
||||
</InnerSideBarFilters>
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
data-testid="sql-editor-new-query-button"
|
||||
variant="default"
|
||||
icon={<Plus className="text-foreground" />}
|
||||
className="w-[26px]"
|
||||
aria-label="Create a new query"
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Create a new query</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="end" side="bottom" className="w-48">
|
||||
<DropdownMenuItem className="gap-x-2" onClick={() => handleNewQuery()}>
|
||||
<FilePlus size={14} />
|
||||
Create a new snippet
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="gap-x-2" onClick={() => createNewFolder()}>
|
||||
<FolderPlus size={14} />
|
||||
Create a new folder
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{showSearch ? <SearchList search={debouncedSearch} /> : <SQLEditorNav sort={sort} />}
|
||||
<div className="flex h-full grow flex-col gap-2 pt-5">
|
||||
<div className="mx-4 flex min-w-0 shrink-0 items-center">
|
||||
<Button
|
||||
data-testid="sql-editor-new-query-button"
|
||||
type="button"
|
||||
size="tiny"
|
||||
variant="default"
|
||||
className="h-7 min-w-0 flex-1 justify-start rounded-r-none hover:z-10"
|
||||
icon={<Plus size={14} strokeWidth={1.5} className="text-foreground-muted" />}
|
||||
onClick={handleNewQuery}
|
||||
>
|
||||
New query
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
aria-label="More query actions"
|
||||
className="h-7 w-7 shrink-0 rounded-l-none -ml-px px-0"
|
||||
icon={<ChevronDown size={14} strokeWidth={1.5} />}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" side="bottom" className="w-52">
|
||||
<DropdownMenuItem className="gap-x-2" onClick={createNewFolder}>
|
||||
<FolderPlus size={14} />
|
||||
New folder
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="gap-x-2" onClick={openRunningQueries}>
|
||||
<List size={14} />
|
||||
View running queries
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t sticky bottom-0 bg-studio">
|
||||
<Button block variant="default" onClick={() => appState.setOnGoingQueriesPanelOpen(true)}>
|
||||
View running queries
|
||||
</Button>
|
||||
<div className="flex min-h-0 grow flex-col gap-2 pb-4">
|
||||
<InnerSideBarFilters className="mx-2">
|
||||
<InnerSideBarFilterSearchInput
|
||||
name="search-queries"
|
||||
placeholder="Search queries..."
|
||||
aria-labelledby="Search queries"
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
setSearch(value)
|
||||
if (value.length === 0) setShowSearch(false)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.code === 'Escape') {
|
||||
setSearch('')
|
||||
setShowSearch(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{showSearch ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
className="absolute right-1 top-[.4rem] transition-colors text-foreground-light hover:text-foreground md:top-[.3rem]"
|
||||
onClick={() => {
|
||||
setSearch('')
|
||||
setShowSearch(false)
|
||||
}}
|
||||
>
|
||||
<X size={18} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Clear search</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<InnerSideBarFilterSortDropdown
|
||||
value={sort}
|
||||
onValueChange={(value) => {
|
||||
if (value === 'name' || value === 'inserted_at') setSort(value)
|
||||
}}
|
||||
>
|
||||
<InnerSideBarFilterSortDropdownItem key="name" value="name">
|
||||
Alphabetical
|
||||
</InnerSideBarFilterSortDropdownItem>
|
||||
<InnerSideBarFilterSortDropdownItem key="inserted_at" value="inserted_at">
|
||||
Created At
|
||||
</InnerSideBarFilterSortDropdownItem>
|
||||
</InnerSideBarFilterSortDropdown>
|
||||
)}
|
||||
</InnerSideBarFilterSearchInput>
|
||||
</InnerSideBarFilters>
|
||||
|
||||
{showSearch ? <SearchList search={debouncedSearch} /> : <SQLEditorNav sort={sort} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { keepPreviousData } from '@tanstack/react-query'
|
||||
import { IS_PLATFORM, LOCAL_STORAGE_KEYS, useParams } from 'common'
|
||||
import { Heart } from 'lucide-react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
@@ -565,9 +564,8 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
|
||||
className="mx-2 px-3"
|
||||
description={
|
||||
<>
|
||||
Save a query to favorites for easy accessibility by clicking the{' '}
|
||||
<Heart size={12} className="inline-block relative align-center -top-px" />{' '}
|
||||
icon.
|
||||
Save a query to favorites from the query options menu in the editor, or by
|
||||
right-clicking a query in the sidebar.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -39,6 +39,7 @@ interface DatabaseSelectorProps {
|
||||
className?: string
|
||||
align?: 'start' | 'end'
|
||||
isForm?: boolean
|
||||
label?: string
|
||||
}
|
||||
|
||||
export const DatabaseSelector = ({
|
||||
@@ -50,6 +51,7 @@ export const DatabaseSelector = ({
|
||||
align = 'end',
|
||||
className,
|
||||
isForm = false,
|
||||
label = 'Source',
|
||||
}: DatabaseSelectorProps) => {
|
||||
const router = useRouter()
|
||||
const { ref: projectRef } = useParams()
|
||||
@@ -80,15 +82,25 @@ export const DatabaseSelector = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [_selectedDatabaseId])
|
||||
|
||||
const selectedDatabaseLabel = selectedAdditionalOption ? (
|
||||
<span>{selectedAdditionalOption.name}</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="capitalize">
|
||||
{isLoading || selectedDatabase?.identifier === projectRef ? 'Primary' : 'Read replica'}
|
||||
</span>
|
||||
{isSuccess && selectedDatabase?.identifier !== projectRef && (
|
||||
<span>
|
||||
({selectedDatabaseRegion} - {formattedDatabaseId})
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen} modal={false}>
|
||||
<PopoverTrigger asChild>
|
||||
<div className={cn('flex cursor-pointer', className)}>
|
||||
{!isForm && (
|
||||
<span className="flex items-center text-foreground-lighter px-3 rounded-lg rounded-r-none text-xs border border-button border-r-0">
|
||||
Source
|
||||
</span>
|
||||
)}
|
||||
{isForm ? (
|
||||
<Button
|
||||
variant="default"
|
||||
icon={isLoading && <Loader2 className="animate-spin" />}
|
||||
@@ -96,31 +108,38 @@ export const DatabaseSelector = ({
|
||||
{...buttonProps}
|
||||
className={cn(
|
||||
'justify-start',
|
||||
!isForm && 'rounded-l-none',
|
||||
variant === 'connected-on-right' && 'rounded-r-none',
|
||||
variant === 'connected-on-left' && 'rounded-l-none border-l-0',
|
||||
variant === 'connected-on-both' && 'rounded-none border-x-0',
|
||||
className,
|
||||
buttonProps?.className
|
||||
)}
|
||||
>
|
||||
{selectedAdditionalOption ? (
|
||||
<span>{selectedAdditionalOption.name}</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="capitalize">
|
||||
{isLoading || selectedDatabase?.identifier === projectRef
|
||||
? 'Primary database'
|
||||
: 'Read replica'}
|
||||
</span>{' '}
|
||||
{isSuccess && selectedDatabase?.identifier !== projectRef && (
|
||||
<span>
|
||||
({selectedDatabaseRegion} - {formattedDatabaseId})
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{selectedDatabaseLabel}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
size="tiny"
|
||||
variant="default"
|
||||
{...buttonProps}
|
||||
className={cn(
|
||||
'h-[26px] justify-start gap-0 pr-3',
|
||||
variant === 'connected-on-right' && 'rounded-r-none border-r-0',
|
||||
variant === 'connected-on-left' && 'rounded-l-none border-l-0',
|
||||
variant === 'connected-on-both' && 'rounded-none border-x-0',
|
||||
className,
|
||||
buttonProps?.className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-foreground-muted">{label}</span>
|
||||
<span className="flex items-center gap-1">
|
||||
{isLoading ? <Loader2 className="animate-spin" size={12} /> : selectedDatabaseLabel}
|
||||
</span>
|
||||
<ChevronDown className="text-muted" strokeWidth={1} size={12} />
|
||||
</div>
|
||||
</Button>
|
||||
)}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0 w-64" side="bottom" align={align}>
|
||||
<Command>
|
||||
@@ -203,7 +222,7 @@ export const DatabaseSelector = ({
|
||||
<div className="w-full flex items-center justify-between">
|
||||
<p>
|
||||
{database.identifier === projectRef
|
||||
? 'Primary database'
|
||||
? 'Primary'
|
||||
: `Read replica (${region} - ${id})`}
|
||||
</p>
|
||||
{database.identifier === selectedDatabaseId && <Check size={16} />}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
calculateSummary,
|
||||
createNodeTree,
|
||||
} from '@/components/interfaces/ExplainVisualizer/ExplainVisualizer.parser'
|
||||
import type { SqlWarehouseResultSource } from '@/components/interfaces/SQLEditor/SqlEditorWarehouseDemo'
|
||||
import { handleError as handleErrorFetchers, post } from '@/data/fetchers'
|
||||
import { MB } from '@/lib/constants'
|
||||
import { sqlEventParser } from '@/lib/sql-event-parser'
|
||||
@@ -65,6 +66,7 @@ type ExecuteSqlVariables = {
|
||||
type ExecuteSqlMutationVariables = ExecuteSqlVariables & {
|
||||
autoLimit?: number
|
||||
contextualInvalidation?: boolean
|
||||
warehouseResultSource?: SqlWarehouseResultSource
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,6 +6,8 @@ export const SQL_EDITOR_SHORTCUT_IDS = {
|
||||
SQL_EDITOR_FORMAT: 'sql-editor.format',
|
||||
SQL_EDITOR_EXPLAIN: 'sql-editor.explain',
|
||||
SQL_EDITOR_NEW_SNIPPET: 'sql-editor.new-snippet',
|
||||
SQL_EDITOR_RUN: 'sql-editor.run',
|
||||
SQL_EDITOR_SAVE: 'sql-editor.save',
|
||||
}
|
||||
|
||||
export type SqlEditorShortcutId =
|
||||
@@ -42,4 +44,16 @@ export const sqlEditorRegistry: RegistryDefinations<SqlEditorShortcutId> = {
|
||||
sequence: ['Shift+N'],
|
||||
showInSettings: false,
|
||||
},
|
||||
[SQL_EDITOR_SHORTCUT_IDS.SQL_EDITOR_RUN]: {
|
||||
id: SQL_EDITOR_SHORTCUT_IDS.SQL_EDITOR_RUN,
|
||||
label: 'Run query',
|
||||
sequence: ['Mod+Enter'],
|
||||
showInSettings: false,
|
||||
},
|
||||
[SQL_EDITOR_SHORTCUT_IDS.SQL_EDITOR_SAVE]: {
|
||||
id: SQL_EDITOR_SHORTCUT_IDS.SQL_EDITOR_SAVE,
|
||||
label: 'Save edits',
|
||||
sequence: ['Mod+S'],
|
||||
showInSettings: false,
|
||||
},
|
||||
}
|
||||
@@ -22,6 +22,19 @@ describe('sqlEditorSessionState', () => {
|
||||
expect(result?.error).toBeUndefined()
|
||||
})
|
||||
|
||||
it('addResult stores Warehouse result metadata', () => {
|
||||
sqlEditorSessionState.addResult('a', [{ x: 1 }], undefined, {
|
||||
type: 'search_path',
|
||||
schema: 'public_warehouse',
|
||||
})
|
||||
|
||||
const result = sqlEditorSessionState.results['a']?.[0]
|
||||
expect(result?.warehouseResultSource).toEqual({
|
||||
type: 'search_path',
|
||||
schema: 'public_warehouse',
|
||||
})
|
||||
})
|
||||
|
||||
it('addResultError stores the error with empty rows', () => {
|
||||
sqlEditorSessionState.addResultError('a', { message: 'boom' }, 25)
|
||||
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { proxy, ref, snapshot, useSnapshot } from 'valtio'
|
||||
|
||||
import type { QueryPlanRow } from '@/components/interfaces/ExplainVisualizer/ExplainVisualizer.types'
|
||||
import type { SqlWarehouseResultSource } from '@/components/interfaces/SQLEditor/SqlEditorWarehouseDemo'
|
||||
|
||||
export interface SqlEditorQueryResult {
|
||||
rows: any[]
|
||||
error?: any
|
||||
autoLimit?: number
|
||||
warehouseResultSource?: SqlWarehouseResultSource
|
||||
}
|
||||
|
||||
/**
|
||||
* Ephemeral, per-session SQL editor state that is NOT persisted: query results,
|
||||
@@ -15,11 +23,7 @@ export const sqlEditorSessionState = proxy({
|
||||
* a single query with multiple statements can return multiple results.
|
||||
*/
|
||||
results: {} as {
|
||||
[snippetId: string]: {
|
||||
rows: any[]
|
||||
error?: any
|
||||
autoLimit?: number
|
||||
}[]
|
||||
[snippetId: string]: SqlEditorQueryResult[]
|
||||
},
|
||||
|
||||
/** EXPLAIN results, if any, keyed by snippet id. */
|
||||
@@ -39,17 +43,27 @@ export const sqlEditorSessionState = proxy({
|
||||
|
||||
setLimit: (value: number) => (sqlEditorSessionState.limit = value),
|
||||
|
||||
addResult: (id: string, results: any[], autoLimit?: number) => {
|
||||
addResult: (
|
||||
id: string,
|
||||
results: any[],
|
||||
autoLimit?: number,
|
||||
warehouseResultSource?: SqlWarehouseResultSource
|
||||
) => {
|
||||
// Use ref() to prevent Valtio from creating proxies for each row object.
|
||||
// This is critical for large result sets - without ref(), Valtio wraps every
|
||||
// row and nested property in a Proxy, causing massive memory overhead.
|
||||
// Alright to use ref() in this case as the data is meant to be read-only and we
|
||||
// don't need to track changes to the underlying data
|
||||
sqlEditorSessionState.results[id] = [{ rows: ref(results), autoLimit }]
|
||||
sqlEditorSessionState.results[id] = [{ rows: ref(results), autoLimit, warehouseResultSource }]
|
||||
},
|
||||
|
||||
addResultError: (id: string, error: any, autoLimit?: number) => {
|
||||
sqlEditorSessionState.results[id] = [{ rows: ref([]), error, autoLimit }]
|
||||
addResultError: (
|
||||
id: string,
|
||||
error: any,
|
||||
autoLimit?: number,
|
||||
warehouseResultSource?: SqlWarehouseResultSource
|
||||
) => {
|
||||
sqlEditorSessionState.results[id] = [{ rows: ref([]), error, autoLimit, warehouseResultSource }]
|
||||
},
|
||||
|
||||
resetResult: (id: string) => {
|
||||
|
||||
@@ -4,26 +4,62 @@ type Investor = {
|
||||
title: string
|
||||
img: string
|
||||
lead: boolean
|
||||
scale?: number
|
||||
objectPosition?: string
|
||||
twitter?: string | null
|
||||
linkedIn?: string | null
|
||||
}
|
||||
|
||||
const data: Investor[] = [
|
||||
// Row 1
|
||||
{
|
||||
name: 'GIC',
|
||||
round: 'B',
|
||||
title: 'GIC',
|
||||
img: '/images/logos/gic--grey.png',
|
||||
lead: true,
|
||||
scale: 1.5,
|
||||
twitter: null,
|
||||
linkedIn: null,
|
||||
},
|
||||
{
|
||||
name: 'Peak XV',
|
||||
round: 'B',
|
||||
title: 'Peak XV Partners',
|
||||
img: '/images/logos/peakxv--grey.png',
|
||||
lead: true,
|
||||
scale: 0.85,
|
||||
twitter: null,
|
||||
linkedIn: null,
|
||||
},
|
||||
{
|
||||
name: 'Accel',
|
||||
round: 'B',
|
||||
title: 'Accel',
|
||||
img: '/images/logos/accel--grey.png',
|
||||
lead: true,
|
||||
scale: 1.12,
|
||||
twitter: null,
|
||||
linkedIn: null,
|
||||
},
|
||||
{
|
||||
name: 'Craft',
|
||||
round: 'B',
|
||||
title: 'Craft Ventures',
|
||||
img: '/images/logos/craft--grey.png',
|
||||
lead: true,
|
||||
scale: 0.61,
|
||||
twitter: null,
|
||||
linkedIn: null,
|
||||
},
|
||||
// Row 2
|
||||
{
|
||||
name: 'Coatue',
|
||||
round: 'SEED',
|
||||
title: 'Coatue Growth Fund',
|
||||
img: '/images/logos/coatue.png',
|
||||
lead: true,
|
||||
twitter: null,
|
||||
linkedIn: null,
|
||||
},
|
||||
{
|
||||
name: 'Mozilla',
|
||||
round: 'SEED',
|
||||
title: 'Mozilla',
|
||||
img: '/images/logos/mozilla--grey.png',
|
||||
lead: true,
|
||||
scale: 0.95,
|
||||
twitter: null,
|
||||
linkedIn: null,
|
||||
},
|
||||
@@ -36,6 +72,16 @@ const data: Investor[] = [
|
||||
twitter: null,
|
||||
linkedIn: null,
|
||||
},
|
||||
{
|
||||
name: 'Stripe',
|
||||
round: 'B',
|
||||
title: 'Stripe',
|
||||
img: '/images/logos/stripe--grey.png',
|
||||
lead: true,
|
||||
scale: 1.15,
|
||||
twitter: null,
|
||||
linkedIn: null,
|
||||
},
|
||||
{
|
||||
name: 'Felicis',
|
||||
round: 'B',
|
||||
@@ -45,6 +91,45 @@ const data: Investor[] = [
|
||||
twitter: null,
|
||||
linkedIn: null,
|
||||
},
|
||||
// Row 3
|
||||
{
|
||||
name: 'Salesforce Ventures',
|
||||
round: 'B',
|
||||
title: 'Salesforce Ventures',
|
||||
img: '/images/logos/salesforce--grey.png',
|
||||
lead: true,
|
||||
scale: 2.01,
|
||||
twitter: null,
|
||||
linkedIn: null,
|
||||
},
|
||||
{
|
||||
name: 'Georgian',
|
||||
round: 'B',
|
||||
title: 'Georgian',
|
||||
img: '/images/logos/georgian--grey.png',
|
||||
lead: true,
|
||||
twitter: null,
|
||||
linkedIn: null,
|
||||
},
|
||||
{
|
||||
name: 'Figma',
|
||||
round: 'B',
|
||||
title: 'Figma',
|
||||
img: '/images/logos/figma--grey.png',
|
||||
lead: true,
|
||||
twitter: null,
|
||||
linkedIn: null,
|
||||
},
|
||||
{
|
||||
name: 'Mozilla',
|
||||
round: 'SEED',
|
||||
title: 'Mozilla',
|
||||
img: '/images/logos/mozilla--grey.png',
|
||||
lead: true,
|
||||
scale: 0.9,
|
||||
twitter: null,
|
||||
linkedIn: null,
|
||||
},
|
||||
{
|
||||
name: 'Tom Preston-Werner',
|
||||
round: 'A',
|
||||
|
||||
@@ -171,24 +171,18 @@ const Investors = () => {
|
||||
<div className="mx-auto mt-5 mb-16 grid max-w-lg gap-0.5 lg:max-w-none lg:grid-cols-4">
|
||||
{InvestorData.filter((x) => x.lead === true).map((x) => (
|
||||
<div key={x.name}>
|
||||
<div
|
||||
className="
|
||||
bg-surface-100
|
||||
col-span-1 flex h-32 content-end
|
||||
items-center justify-center"
|
||||
>
|
||||
<div className="relative h-8 w-full overflow-auto">
|
||||
<div className="bg-surface-100 col-span-1 flex h-32 content-end items-center justify-center">
|
||||
<div
|
||||
className="relative w-full overflow-hidden"
|
||||
style={{ height: `${(x.scale ?? 1) * 2}rem` }}
|
||||
>
|
||||
<Image
|
||||
layout="fill"
|
||||
src={`${x.img}`}
|
||||
alt={x.name}
|
||||
objectFit="scale-down"
|
||||
objectPosition="center"
|
||||
className="
|
||||
opacity-50
|
||||
contrast-0
|
||||
filter
|
||||
"
|
||||
className="opacity-50 contrast-0 filter"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
@@ -47,6 +47,7 @@ export const LOCAL_STORAGE_KEYS = {
|
||||
SQL_EDITOR_AI_SCHEMA: (ref: string) => `supabase_sql-editor-ai-schema-enabled-${ref}`,
|
||||
SQL_EDITOR_AI_OPEN: 'supabase_sql-editor-ai-open',
|
||||
SQL_EDITOR_LAST_SELECTED_DB: (ref: string) => `sql-editor-last-selected-db-${ref}`,
|
||||
SQL_EDITOR_SEARCH_PATH: (ref: string) => `sql-editor-search-path-${ref}`,
|
||||
SQL_EDITOR_QUERY_TARGET: (ref: string) => `sql-editor-query-target-${ref}`,
|
||||
SQL_EDITOR_SQL_BLOCK_ACKNOWLEDGED: (ref: string) => `sql-editor-sql-block-acknowledged-${ref}`,
|
||||
SQL_EDITOR_SECTION_STATE: (ref: string) => `sql-editor-section-state-${ref}`,
|
||||
|
||||