mirror of
https://github.com/supabase/supabase.git
synced 2026-10-09 19:35:06 +03:00
681 lines
19 KiB
JavaScript
681 lines
19 KiB
JavaScript
import Split from 'react-split'
|
|
import Editor from '@monaco-editor/react'
|
|
import DataGrid from '@supabase/react-data-grid'
|
|
import { toJS } from 'mobx'
|
|
import { CSVLink } from 'react-csv'
|
|
import { debounce } from 'lodash'
|
|
import { observer } from 'mobx-react-lite'
|
|
import { useEffect, useRef, useState } from 'react'
|
|
import {
|
|
Button,
|
|
Dropdown,
|
|
IconAlertCircle,
|
|
IconCheck,
|
|
IconChevronDown,
|
|
IconChevronUp,
|
|
IconHeart,
|
|
IconLoader,
|
|
IconRefreshCcw,
|
|
Typography,
|
|
} from '@supabase/ui'
|
|
import * as Tooltip from '@radix-ui/react-tooltip'
|
|
|
|
import { useKeyboardShortcuts, useStore, useWindowDimensions, usePrevious } from 'hooks'
|
|
import Telemetry from 'lib/telemetry'
|
|
import { IS_PLATFORM } from 'lib/constants'
|
|
import { copyToClipboard, timeout } from 'lib/helpers'
|
|
import { useSqlStore, UTILITY_TAB_TYPES } from 'localStores/sqlEditor/SqlEditorStore'
|
|
import { SQL_SNIPPET_SCHEMA_VERSION } from './SqlEditor.constants'
|
|
|
|
const TabSqlQuery = observer(() => {
|
|
const { content: contentStore } = useStore()
|
|
const sqlEditorStore = useSqlStore()
|
|
const { height: screenHeight } = useWindowDimensions()
|
|
const snapOffset = 50
|
|
const minSize = 44
|
|
const tabMenuHeight = 44
|
|
const offset = 3
|
|
|
|
useEffect(() => {
|
|
// minus fixed tabMenu height
|
|
const utilityPanelHeight =
|
|
(parseFloat(screenHeight - tabMenuHeight) * sqlEditorStore.activeTab.splitSizes[1]) / 100
|
|
if (utilityPanelHeight - snapOffset < minSize) {
|
|
sqlEditorStore.activeTab.resizeUtilityTab(0)
|
|
} else {
|
|
sqlEditorStore.activeTab.resizeUtilityTab(utilityPanelHeight - minSize - offset)
|
|
}
|
|
}, [screenHeight, sqlEditorStore.activeTab.splitSizes])
|
|
|
|
function onDragEnd(sizes) {
|
|
sqlEditorStore.activeTab.setSplitSizes(sizes)
|
|
}
|
|
|
|
async function updateSqlSnippet(value) {
|
|
if (sqlEditorStore.activeTab) {
|
|
await contentStore.updateSql(sqlEditorStore.activeTab.id, {
|
|
content: {
|
|
schema_version: SQL_SNIPPET_SCHEMA_VERSION,
|
|
content_id: sqlEditorStore.activeTab.id,
|
|
sql: value,
|
|
favorite: sqlEditorStore.activeTab.favorite,
|
|
},
|
|
})
|
|
} else {
|
|
console.warn('No active tab found while updating SQL snippet')
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="flex h-full flex-col">
|
|
<Split
|
|
style={{ height: '100%' }}
|
|
direction="vertical"
|
|
gutterSize={2}
|
|
sizes={sqlEditorStore.activeTab.splitSizes || []}
|
|
minSize={minSize}
|
|
snapOffset={snapOffset}
|
|
expandToMin={true}
|
|
collapsed={sqlEditorStore.activeTab.utilityTabHeight == 0 ? 1 : undefined}
|
|
onDragEnd={onDragEnd}
|
|
>
|
|
<MonacoEditor
|
|
error={sqlEditorStore.activeTab.sqlQueryError}
|
|
updateSqlSnippet={updateSqlSnippet}
|
|
/>
|
|
<div>
|
|
<UtilityPanel updateSqlSnippet={updateSqlSnippet} />
|
|
</div>
|
|
</Split>
|
|
</div>
|
|
)
|
|
})
|
|
export default TabSqlQuery
|
|
|
|
const MonacoEditor = ({ error, updateSqlSnippet }) => {
|
|
const sqlEditorStore = useSqlStore()
|
|
const editorRef = useRef(null)
|
|
const monacoRef = useRef(null)
|
|
|
|
useEffect(() => {
|
|
if (!editorRef.current || !monacoRef.current) return
|
|
|
|
if (!error) {
|
|
const model = editorRef.current.getModel()
|
|
monacoRef.current.editor.setModelMarkers(model, 'owner', [])
|
|
} else if (error?.cursorPosition) {
|
|
const model = editorRef.current.getModel()
|
|
const position = model.getPositionAt(error.cursorPosition)
|
|
monacoRef.current.editor.setModelMarkers(model, 'owner', [
|
|
{
|
|
startLineNumber: position.lineNumber,
|
|
endLineNumber: 1,
|
|
startColumn: position.column,
|
|
endColumn: 0,
|
|
message: error.message || 'syntax error',
|
|
severity: monacoRef.current.MarkerSeverity.Error,
|
|
},
|
|
])
|
|
}
|
|
}, [error])
|
|
|
|
useEffect(() => {
|
|
if (editorRef.current) {
|
|
// add margin above first line
|
|
editorRef.current?.changeViewZones((accessor) => {
|
|
accessor.addZone({
|
|
afterLineNumber: 0,
|
|
heightInPx: 4,
|
|
domNode: document.createElement('div'),
|
|
})
|
|
})
|
|
}
|
|
}, [sqlEditorStore.activeTab.id])
|
|
|
|
async function handleEditorOnMount(editor, monaco) {
|
|
editorRef.current = editor
|
|
monacoRef.current = monaco
|
|
|
|
editor.addAction({
|
|
id: 'supabase',
|
|
label: 'Run Query',
|
|
keybindings: [monaco.KeyMod.CtrlCmd + monaco.KeyCode.Enter],
|
|
contextMenuGroupId: 'operation',
|
|
contextMenuOrder: 0,
|
|
run: async () => {
|
|
if (sqlEditorStore.isExecuting) return
|
|
|
|
const selectedValue = (editorRef?.current)
|
|
.getModel()
|
|
.getValueInRange(editorRef?.current?.getSelection())
|
|
|
|
await sqlEditorStore.startExecuting(selectedValue)
|
|
|
|
// onInputRun(selectedValue || (editorRef?.current as any)?.getValue())
|
|
},
|
|
})
|
|
|
|
// add margin above first line
|
|
editor.changeViewZones((accessor) => {
|
|
accessor.addZone({
|
|
afterLineNumber: 0,
|
|
heightInPx: 4,
|
|
domNode: document.createElement('div'),
|
|
})
|
|
})
|
|
|
|
// when editor did mount, it will need a delay before focus() works properly
|
|
await timeout(500)
|
|
editor?.focus()
|
|
}
|
|
|
|
// changes are stored in debouncer before running persistData()
|
|
let debounceUpdateSqlSnippet = debounce((value) => updateSqlSnippet(value), 1500)
|
|
|
|
async function handleEditorChange(value) {
|
|
// update sqlEditorStore with new value immediately
|
|
// this is so any SQL run will be whatever is currently in monaco editor
|
|
sqlEditorStore.activeTab.setQuery(value)
|
|
|
|
// debounce changes
|
|
debounceUpdateSqlSnippet(value)
|
|
}
|
|
|
|
return (
|
|
<div className="dark:border-dark flex-grow overflow-y-auto border-b">
|
|
<Editor
|
|
className="monaco-editor"
|
|
theme={'supabase'}
|
|
onMount={handleEditorOnMount}
|
|
onChange={handleEditorChange}
|
|
defaultLanguage="pgsql"
|
|
defaultValue={sqlEditorStore.activeTab.query}
|
|
path={sqlEditorStore.activeTab.id}
|
|
options={{
|
|
tabSize: 2,
|
|
fontSize: 13,
|
|
minimap: {
|
|
enabled: false,
|
|
},
|
|
wordWrap: 'on',
|
|
fixedOverflowWidgets: true,
|
|
}}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const UtilityPanel = observer(({ updateSqlSnippet }) => {
|
|
const sqlEditorStore = useSqlStore()
|
|
|
|
return (
|
|
<>
|
|
<div className="flex justify-between overflow-visible px-6 py-2">
|
|
<ResultsDropdown />
|
|
<div className="inline-flex items-center justify-end">
|
|
<UtilityActions updateSqlSnippet={updateSqlSnippet} />
|
|
</div>
|
|
</div>
|
|
<div className="p-0 pt-0 pb-0">
|
|
{sqlEditorStore.activeTab.activeUtilityTab == UTILITY_TAB_TYPES.RESULTS && (
|
|
<UtilityTabResults />
|
|
)}
|
|
</div>
|
|
</>
|
|
)
|
|
})
|
|
|
|
const ResultsDropdown = observer(() => {
|
|
const { ui } = useStore()
|
|
const sqlEditorStore = useSqlStore()
|
|
const csvRef = useRef(null)
|
|
|
|
function onDownloadCSV() {
|
|
csvRef.current?.link.click()
|
|
Telemetry.sendEvent('sql_editor', 'sql_download_csv', '')
|
|
}
|
|
|
|
function onCopyAsMarkdown() {
|
|
if (navigator) {
|
|
copyToClipboard(sqlEditorStore.activeTab.markdownData, () => {
|
|
ui.setNotification({ category: 'success', message: 'Copied results to clipboard' })
|
|
Telemetry.sendEvent('sql_editor', 'sql_copy_as_markdown', '')
|
|
})
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Dropdown
|
|
side="bottom"
|
|
align="start"
|
|
overlay={
|
|
<>
|
|
<Dropdown.Item onClick={onDownloadCSV}>Download CSV</Dropdown.Item>
|
|
<Dropdown.Item onClick={onCopyAsMarkdown}>Copy as markdown</Dropdown.Item>
|
|
</>
|
|
}
|
|
>
|
|
<Button as="span" type="text" iconRight={<IconChevronDown />}>
|
|
Results
|
|
</Button>
|
|
<CSVLink
|
|
ref={csvRef}
|
|
className="hidden"
|
|
data={sqlEditorStore.activeTab?.csvData || ''}
|
|
filename={`supabase_${sqlEditorStore.projectRef}_${sqlEditorStore.activeTab.name}`}
|
|
/>
|
|
</Dropdown>
|
|
)
|
|
})
|
|
|
|
const SavingIndicator = observer(({ updateSqlSnippet }) => {
|
|
const { content } = useStore()
|
|
const sqlEditorStore = useSqlStore()
|
|
const previousState = usePrevious(content.savingState)
|
|
const [showSavedText, setShowSavedText] = useState(false)
|
|
|
|
useEffect(() => {
|
|
let cancel = false
|
|
|
|
if (
|
|
(previousState === 'CREATING' || previousState === 'UPDATING') &&
|
|
content.savingState === 'IDLE'
|
|
) {
|
|
setShowSavedText(true)
|
|
setTimeout(() => {
|
|
if (!cancel) setShowSavedText(false)
|
|
}, 3000)
|
|
}
|
|
|
|
return () => (cancel = true)
|
|
}, [content.savingState])
|
|
|
|
const retry = () => {
|
|
const [item] = content.list((item) => item.id === sqlEditorStore.selectedTabId)
|
|
|
|
if (content.savingState === 'CREATING_FAILED' && item) {
|
|
content.save(toJS(item))
|
|
}
|
|
|
|
if (content.savingState === 'UPDATING_FAILED' && item) {
|
|
updateSqlSnippet(sqlEditorStore.activeTab.query)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="mx-2 flex items-center gap-2">
|
|
{(content.savingState === 'CREATING_FAILED' || content.savingState === 'UPDATING_FAILED') && (
|
|
<Button
|
|
type="text"
|
|
size="tiny"
|
|
shadow={false}
|
|
icon={<IconRefreshCcw className="text-gray-1100" size="tiny" strokeWidth={2} />}
|
|
onClick={retry}
|
|
>
|
|
Retry
|
|
</Button>
|
|
)}
|
|
{(content.savingState === 'CREATING' || content.savingState === 'UPDATING') && (
|
|
<Tooltip.Root delayDuration={0}>
|
|
<Tooltip.Trigger>
|
|
<IconLoader className="animate-spin" size={14} strokeWidth={2} />
|
|
</Tooltip.Trigger>
|
|
<Tooltip.Content side="bottom">
|
|
<Tooltip.Arrow className="radix-tooltip-arrow" />
|
|
<div
|
|
className={[
|
|
'bg-scale-100 rounded py-1 px-2 leading-none shadow',
|
|
'border-scale-200 border',
|
|
].join(' ')}
|
|
>
|
|
<span className="text-scale-1200 text-xs">Saving changes...</span>
|
|
</div>
|
|
</Tooltip.Content>
|
|
</Tooltip.Root>
|
|
)}
|
|
{(content.savingState === 'CREATING_FAILED' || content.savingState === 'UPDATING_FAILED') && (
|
|
<IconAlertCircle className="text-red-900" size={14} strokeWidth={2} />
|
|
)}
|
|
{showSavedText && (
|
|
<Tooltip.Root delayDuration={0}>
|
|
<Tooltip.Trigger>
|
|
<IconCheck className="text-brand-900" size={14} strokeWidth={3} />
|
|
</Tooltip.Trigger>
|
|
<Tooltip.Content side="bottom">
|
|
<Tooltip.Arrow className="radix-tooltip-arrow" />
|
|
<div
|
|
className={[
|
|
'bg-scale-100 rounded py-1 px-2 leading-none shadow',
|
|
'border-scale-200 border ',
|
|
].join(' ')}
|
|
>
|
|
<span className="text-scale-1200 text-xs">All changes saved</span>
|
|
</div>
|
|
</Tooltip.Content>
|
|
</Tooltip.Root>
|
|
)}
|
|
<span className="text-scale-1000 text-sm">
|
|
{content.savingState === 'CREATING_FAILED' && 'Failed to create'}
|
|
{content.savingState === 'UPDATING_FAILED' && 'Failed to save'}
|
|
</span>
|
|
</div>
|
|
)
|
|
})
|
|
|
|
const UtilityActions = observer(({ updateSqlSnippet }) => {
|
|
const sqlEditorStore = useSqlStore()
|
|
|
|
useKeyboardShortcuts(
|
|
{
|
|
'Command+Enter': (event) => {
|
|
event.preventDefault()
|
|
executeQuery()
|
|
},
|
|
},
|
|
['INPUT']
|
|
)
|
|
|
|
async function executeQuery() {
|
|
if (sqlEditorStore.isExecuting) return
|
|
await sqlEditorStore.startExecuting()
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<SavingIndicator updateSqlSnippet={updateSqlSnippet} />
|
|
{IS_PLATFORM && <FavoriteButton />}
|
|
<SizeToggleButton />
|
|
<Button
|
|
onClick={executeQuery}
|
|
disabled={sqlEditorStore.isExecuting}
|
|
loading={sqlEditorStore.isExecuting}
|
|
type="text"
|
|
size="tiny"
|
|
shadow={false}
|
|
className="mx-2"
|
|
>
|
|
RUN
|
|
</Button>
|
|
</>
|
|
)
|
|
})
|
|
|
|
const SizeToggleButton = observer(() => {
|
|
const sqlEditorStore = useSqlStore()
|
|
|
|
function maximizeEditor() {
|
|
sqlEditorStore.activeTab.collapseUtilityTab()
|
|
}
|
|
|
|
function restorePanelSize() {
|
|
sqlEditorStore.activeTab.restorePanelSize()
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{sqlEditorStore.activeTab.utilityTabHeight != 0 && (
|
|
<Button
|
|
type="text"
|
|
size="tiny"
|
|
shadow={false}
|
|
onClick={maximizeEditor}
|
|
icon={<IconChevronDown className="text-gray-1100" size="tiny" strokeWidth={2} />}
|
|
tooltip={{
|
|
title: 'Maximize editor',
|
|
position: 'top',
|
|
}}
|
|
/>
|
|
)}
|
|
{sqlEditorStore.activeTab.utilityTabHeight == 0 && (
|
|
<Button
|
|
type="text"
|
|
size="tiny"
|
|
shadow={false}
|
|
onClick={restorePanelSize}
|
|
icon={<IconChevronUp className="text-gray-1100" size="tiny" strokeWidth={2} />}
|
|
tooltip={{
|
|
title: 'Restore panel size',
|
|
position: 'top',
|
|
}}
|
|
/>
|
|
)}
|
|
</>
|
|
)
|
|
})
|
|
|
|
const FavoriteButton = observer(() => {
|
|
const { ui, content: contentStore } = useStore()
|
|
const { profile: user } = ui
|
|
|
|
const sqlEditorStore = useSqlStore()
|
|
|
|
const [loading, setLoading] = useState(false)
|
|
|
|
const id = sqlEditorStore.activeTab.id
|
|
|
|
/*
|
|
* `content` column json structure
|
|
*/
|
|
let contentPayload = {
|
|
schema_version: '1.0',
|
|
content_id: id,
|
|
sql: sqlEditorStore.activeTab.query,
|
|
}
|
|
|
|
async function addToFavorite() {
|
|
try {
|
|
setLoading(true)
|
|
/*
|
|
* remote db handling
|
|
*/
|
|
await contentStore.updateSql(id, {
|
|
content: {
|
|
...contentPayload,
|
|
favorite: true,
|
|
},
|
|
})
|
|
|
|
/*
|
|
* old localstorage handling
|
|
*/
|
|
const { query, name, desc } = sqlEditorStore.activeTab || {}
|
|
sqlEditorStore.addToFavorite(id, query, name, desc)
|
|
Telemetry.sendEvent('sql_editor', 'sql_favourited', name)
|
|
|
|
/*
|
|
* reload sql data in store and re-select tab
|
|
*/
|
|
sqlEditorStore.loadTabs(sqlEditorStore.tabsFromContentStore(contentStore, user?.id), false)
|
|
sqlEditorStore.selectTab(id)
|
|
|
|
setLoading(false)
|
|
} catch (error) {
|
|
ui.setNotification({
|
|
error,
|
|
category: 'error',
|
|
message: `Failed to add to favourites: ${error.message}`,
|
|
})
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
async function unFavorite() {
|
|
const id = sqlEditorStore.activeTab.id
|
|
try {
|
|
setLoading(true)
|
|
/*
|
|
* remote db handling
|
|
*/
|
|
await contentStore.updateSql(id, {
|
|
content: {
|
|
...contentPayload,
|
|
favorite: false,
|
|
},
|
|
})
|
|
|
|
/*
|
|
* old localstorage handling
|
|
*/
|
|
const { name } = sqlEditorStore.activeTab || {}
|
|
sqlEditorStore.unFavorite(id)
|
|
Telemetry.sendEvent('sql_editor', 'sql_unfavourited', name)
|
|
|
|
/*
|
|
* reload sql data in store and re-select tab
|
|
*/
|
|
sqlEditorStore.loadTabs(sqlEditorStore.tabsFromContentStore(contentStore, user?.id), false)
|
|
sqlEditorStore.selectTab(id)
|
|
setLoading(false)
|
|
} catch (error) {
|
|
ui.setNotification({
|
|
error,
|
|
category: 'error',
|
|
message: `Failed to remove from favourites: ${error.message}`,
|
|
})
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{sqlEditorStore.activeTab.favorite ? (
|
|
<Button
|
|
type="text"
|
|
size="tiny"
|
|
shadow={false}
|
|
onClick={unFavorite}
|
|
loading={loading}
|
|
icon={<IconHeart size="tiny" fill="#48bb78" />}
|
|
/>
|
|
) : (
|
|
<Button
|
|
type="text"
|
|
size="tiny"
|
|
shadow={false}
|
|
onClick={addToFavorite}
|
|
loading={loading}
|
|
icon={<IconHeart size="tiny" fill="gray" />}
|
|
/>
|
|
)}
|
|
</>
|
|
)
|
|
})
|
|
|
|
const UtilityTabResults = observer(() => {
|
|
const sqlEditorStore = useSqlStore()
|
|
|
|
if (sqlEditorStore.activeTab.utilityTabHeight == 0) return null
|
|
if (sqlEditorStore.activeTab.isExecuting) {
|
|
return (
|
|
<div className="bg-table-header-light dark:bg-table-header-dark">
|
|
<p className="m-0 border-0 px-6 py-4 font-mono text-sm">Running...</p>
|
|
</div>
|
|
)
|
|
} else if (sqlEditorStore.activeTab.errorResult) {
|
|
return (
|
|
<div className="bg-table-header-light dark:bg-table-header-dark">
|
|
<Typography.Text>
|
|
<p className="m-0 border-0 px-6 py-4 font-mono">{sqlEditorStore.activeTab.errorResult}</p>
|
|
</Typography.Text>
|
|
</div>
|
|
)
|
|
} else if (sqlEditorStore.activeTab.hasNoResult) {
|
|
return (
|
|
<div className="bg-table-header-light dark:bg-table-header-dark">
|
|
<Typography.Text type="secondary">
|
|
<p className="m-0 border-0 px-6 py-4 ">
|
|
Click <Typography.Text code>RUN</Typography.Text> to execute your query.
|
|
</p>
|
|
</Typography.Text>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div style={{ height: sqlEditorStore.activeTab.utilityTabHeight }}>
|
|
<Results results={sqlEditorStore.activeTab.results} />
|
|
</div>
|
|
)
|
|
})
|
|
|
|
const Results = ({ results }) => {
|
|
const [cellPosition, setCellPosition] = useState(undefined)
|
|
|
|
useKeyboardShortcuts(
|
|
{
|
|
'Command+c': (event) => {
|
|
event.stopPropagation()
|
|
onCopyCell()
|
|
},
|
|
'Control+c': (event) => {
|
|
event.stopPropagation()
|
|
onCopyCell()
|
|
},
|
|
},
|
|
['INPUT', 'TEXTAREA']
|
|
)
|
|
|
|
if (results?.error) {
|
|
return (
|
|
<div className="bg-table-header-light dark:bg-table-header-dark">
|
|
<Typography.Text type="danger">
|
|
<p className="m-0 border-0 px-6 py-4 font-mono"> {`ERROR: ${results.error}`}</p>
|
|
</Typography.Text>
|
|
</div>
|
|
)
|
|
}
|
|
if (!results.length) {
|
|
return (
|
|
<div className="bg-table-header-light dark:bg-table-header-dark">
|
|
<p className="m-0 border-0 px-6 py-4 font-mono text-sm">Success. No rows returned</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const formatter = (column, row) => {
|
|
return <span className="font-mono text-xs">{JSON.stringify(row[column])}</span>
|
|
}
|
|
const columnRender = (name) => {
|
|
return <div className="flex h-full items-center justify-center font-mono">{name}</div>
|
|
}
|
|
const columns = Object.keys(results[0]).map((key) => ({
|
|
key,
|
|
name: key,
|
|
formatter: ({ row }) => formatter(key, row),
|
|
headerRenderer: () => columnRender(key),
|
|
resizable: true,
|
|
width: 120,
|
|
}))
|
|
|
|
function onSelectedCellChange(position) {
|
|
setCellPosition(position)
|
|
}
|
|
|
|
function onCopyCell() {
|
|
if (columns && cellPosition) {
|
|
const { idx, rowIdx } = cellPosition
|
|
const colKey = columns[idx].key
|
|
const cellValue = results[rowIdx]?.[colKey] ?? ''
|
|
const value = formatClipboardValue(cellValue)
|
|
copyToClipboard(value)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<DataGrid
|
|
columns={columns}
|
|
rows={results}
|
|
style={{ height: '100%' }}
|
|
onSelectedCellChange={onSelectedCellChange}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function formatClipboardValue(value) {
|
|
if (value === null) return ''
|
|
if (typeof value == 'object' || Array.isArray(value)) {
|
|
return JSON.stringify(value)
|
|
}
|
|
return value
|
|
}
|