feat(tabs): SQL editor global tabs (#38243)

Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Marius Andra
2025-09-18 15:32:15 +01:00
committed by GitHub
parent 35a80c834c
commit 367435b2ad
26 changed files with 439 additions and 1374 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -820,10 +820,14 @@ export const projectTreeLogic = kea<projectTreeLogicType>([
setActivePanelIdentifier: () => {
// clear search term when changing panel
actions.clearSearch()
actions.setProjectTreeMode('tree')
if (values.projectTreeMode !== 'tree') {
actions.setProjectTreeMode('tree')
}
},
resetPanelLayout: () => {
actions.setProjectTreeMode('tree')
if (values.projectTreeMode !== 'tree') {
actions.setProjectTreeMode('tree')
}
},
loadFolderSuccess: ({ folder }) => {
if (folder === '') {

View File

@@ -1,49 +0,0 @@
import React, { useEffect, useRef } from 'react'
interface AutoTabProps {
value: string
onChange: React.ChangeEventHandler<HTMLInputElement>
onKeyDown?: React.KeyboardEventHandler<HTMLInputElement>
onBlur: React.FocusEventHandler<HTMLInputElement>
autoFocus?: boolean
}
/**
* Tab component that automatically resizes an input field to match the width of its content based upon
* the width of a hidden span element.
*/
const AutoTab = ({ value, onChange, onKeyDown, onBlur, autoFocus }: AutoTabProps): JSX.Element => {
const inputRef = useRef<HTMLInputElement>(null)
const spanRef = useRef<HTMLSpanElement>(null)
useEffect(() => {
if (!inputRef.current || !spanRef.current) {
return
}
const newWidth = spanRef.current.offsetWidth
inputRef.current.style.width = newWidth + 'px'
}, [value])
const handleChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
onChange(e)
}
return (
<div className="relative inline-block">
<span ref={spanRef} className="pointer-events-none absolute invisible whitespace-pre" aria-hidden="true">
{value}
</span>
<input
ref={inputRef}
className="bg-transparent border-none focus:outline-hidden p-0"
value={value}
onChange={handleChange}
onKeyDown={onKeyDown}
onBlur={onBlur}
autoFocus={autoFocus}
/>
</div>
)
}
export default AutoTab

View File

@@ -2,10 +2,11 @@ import './EditorScene.scss'
import { Monaco } from '@monaco-editor/react'
import { BindLogic, useActions, useValues } from 'kea'
import { router } from 'kea-router'
import type { editor as importedEditor } from 'monaco-editor'
import { useRef, useState } from 'react'
import { SceneExport } from 'scenes/sceneTypes'
import { DataNodeLogicProps } from '~/queries/nodes/DataNode/dataNodeLogic'
import { dataNodeLogic } from '~/queries/nodes/DataNode/dataNodeLogic'
import { variableModalLogic } from '~/queries/nodes/DataVisualization/Components/Variables/variableModalLogic'
@@ -23,7 +24,12 @@ import { editorSizingLogic } from './editorSizingLogic'
import { multitabEditorLogic } from './multitabEditorLogic'
import { outputPaneLogic } from './outputPaneLogic'
export function EditorScene(): JSX.Element {
export const scene: SceneExport = {
logic: multitabEditorLogic,
component: EditorScene,
}
export function EditorScene({ tabId }: { tabId?: string }): JSX.Element {
const ref = useRef(null)
const navigatorRef = useRef(null)
const queryPaneRef = useRef(null)
@@ -54,10 +60,9 @@ export function EditorScene(): JSX.Element {
null as [Monaco, importedEditor.IStandaloneCodeEditor] | null
)
const [monaco, editor] = monacoAndEditor ?? []
const codeEditorKey = `hogQLQueryEditor/${router.values.location.pathname}`
const logic = multitabEditorLogic({
key: codeEditorKey,
tabId: tabId || '',
monaco,
editor,
})
@@ -85,20 +90,9 @@ export function EditorScene(): JSX.Element {
dataNodeCollectionId: dataLogicKey,
variablesOverride: undefined,
autoLoad: false,
onData: (data) => {
const mountedLogic = multitabEditorLogic.findMounted({
key: codeEditorKey,
monaco,
editor,
})
if (mountedLogic) {
mountedLogic.actions.setResponse(data ?? null)
}
},
onError: (error) => {
const mountedLogic = multitabEditorLogic.findMounted({
key: codeEditorKey,
tabId: tabId || '',
monaco,
editor,
})
@@ -130,16 +124,14 @@ export function EditorScene(): JSX.Element {
<BindLogic logic={variablesLogic} props={variablesLogicProps}>
<BindLogic logic={variableModalLogic} props={{ key: dataVisualizationLogicProps.key }}>
<BindLogic logic={outputPaneLogic} props={{}}>
<BindLogic
logic={multitabEditorLogic}
props={{ key: codeEditorKey, monaco, editor }}
>
<BindLogic logic={multitabEditorLogic} props={{ tabId, monaco, editor }}>
<div
data-attr="editor-scene"
className="EditorScene w-full h-[calc(var(--scene-layout-rect-height)-var(--scene-layout-header-height))] flex flex-row overflow-hidden"
ref={ref}
>
<QueryWindow
tabId={tabId || ''}
onSetMonacoAndEditor={(monaco, editor) =>
setMonacoAndEditor([monaco, editor])
}

View File

@@ -258,22 +258,14 @@ function RowDetailsModal({ isOpen, onClose, row, columns }: RowDetailsModalProps
)
}
export function OutputPane(): JSX.Element {
export function OutputPane({ tabId }: { tabId: string }): JSX.Element {
const { activeTab } = useValues(outputPaneLogic)
const { setActiveTab } = useActions(outputPaneLogic)
const { editingView } = useValues(multitabEditorLogic)
const { featureFlags } = useValues(featureFlagLogic)
const {
sourceQuery,
exportContext,
editorKey,
editingInsight,
updateInsightButtonEnabled,
showLegacyFilters,
localStorageResponse,
queryInput,
} = useValues(multitabEditorLogic)
const { sourceQuery, exportContext, editingInsight, updateInsightButtonEnabled, showLegacyFilters, queryInput } =
useValues(multitabEditorLogic)
const { saveAsInsight, updateInsight, setSourceQuery, runQuery, shareTab } = useActions(multitabEditorLogic)
const { isDarkModeOn } = useValues(themeLogic)
const {
@@ -286,7 +278,7 @@ export function OutputPane(): JSX.Element {
const { queryCancelled } = useValues(dataVisualizationLogic)
const { toggleChartSettingsPanel } = useActions(dataVisualizationLogic)
const response = (dataNodeResponse ?? localStorageResponse) as HogQLQueryResponse | undefined
const response = dataNodeResponse as HogQLQueryResponse | undefined
const [progressCache, setProgressCache] = useState<Record<string, number>>({})
@@ -614,15 +606,13 @@ export function OutputPane(): JSX.Element {
saveAsInsight={saveAsInsight}
queryId={queryId}
pollResponse={pollResponse}
editorKey={editorKey}
tabId={tabId}
setProgress={setProgress}
progress={queryId ? progressCache[queryId] : undefined}
/>
</div>
<div className="flex justify-between px-2 border-t">
<div>
{response && !responseError ? <LoadPreviewText localResponse={localStorageResponse} /> : <></>}
</div>
<div>{response && !responseError ? <LoadPreviewText localResponse={response} /> : <></>}</div>
<div className="flex items-center gap-4">
{featureFlags[FEATURE_FLAGS.QUERY_EXECUTION_DETAILS] ? <QueryExecutionDetails /> : <ElapsedTime />}
</div>
@@ -750,7 +740,7 @@ const Content = ({
rows,
isDarkModeOn,
vizKey,
editorKey,
tabId,
setSourceQuery,
exportContext,
saveAsInsight,
@@ -792,7 +782,7 @@ const Content = ({
return (
<TabScroller>
<div className="px-6 py-4 border-t">
<QueryInfo codeEditorKey={editorKey} />
<QueryInfo tabId={tabId} />
</div>
</TabScroller>
)

View File

@@ -1,108 +0,0 @@
import { Meta, StoryFn, StoryObj } from '@storybook/react'
import { BindLogic } from 'kea'
import { Uri, UriComponents } from 'monaco-editor'
import { QueryTabs } from './QueryTabs'
import { QueryTab, multitabEditorLogic } from './multitabEditorLogic'
type Story = StoryObj<typeof QueryTabs>
const meta: Meta<typeof QueryTabs> = {
title: 'Scenes-App/Data Warehouse/QueryTabs',
component: QueryTabs,
parameters: {
layout: 'fullscreen',
viewMode: 'story',
},
tags: ['autodocs'],
}
export default meta
const Template: StoryFn<typeof QueryTabs> = (args) => {
return (
<BindLogic logic={multitabEditorLogic} props={{ key: 'new' }}>
<QueryTabs {...args} />
</BindLogic>
)
}
const mockModels: QueryTab[] = [
{
uri: {
path: '/query1.sql',
scheme: 'file',
authority: '',
query: '',
fragment: '',
fsPath: '',
with: function (change: {
scheme?: string
authority?: string | null
path?: string | null
query?: string | null
fragment?: string | null
}): Uri {
change.path = '/query1.sql'
return this
},
toJSON: function (): UriComponents {
return {
path: '/query1.sql',
scheme: 'file',
authority: '',
query: '',
fragment: '',
}
},
},
name: 'Query 1',
view: undefined,
},
{
uri: {
path: '/query2.sql',
scheme: 'file',
authority: '',
query: '',
fragment: '',
fsPath: '',
with: function (change: {
scheme?: string
authority?: string | null
path?: string | null
query?: string | null
fragment?: string | null
}): Uri {
change.path = '/query1.sql'
return this
},
toJSON: function (): UriComponents {
return {
path: '/query1.sql',
scheme: 'file',
authority: '',
query: '',
fragment: '',
}
},
},
name: 'Query 2',
},
]
export const Default: Story = Template.bind({})
Default.args = {
models: mockModels,
activeModelUri: mockModels[0],
}
export const SingleTab: Story = Template.bind({})
SingleTab.args = {
models: [mockModels[0]],
activeModelUri: mockModels[0],
}
export const NoActiveTabs: Story = Template.bind({})
NoActiveTabs.args = {
models: mockModels,
activeModelUri: null,
}

View File

@@ -1,137 +0,0 @@
import clsx from 'clsx'
import { useValues } from 'kea'
import { useEffect, useRef, useState } from 'react'
import { IconPlus, IconX } from '@posthog/icons'
import { LemonButton } from '@posthog/lemon-ui'
import AutoTab from './AutoTab'
import { NEW_QUERY, QueryTab, multitabEditorLogic } from './multitabEditorLogic'
interface QueryTabsProps {
models: QueryTab[]
onClick: (model: QueryTab) => void
onClear: (model: QueryTab, options?: { force?: boolean }) => void
onRename: (model: QueryTab, newName: string) => void
onAdd: () => void
activeModelUri: QueryTab | null
}
export function QueryTabs({ models, onClear, onClick, onAdd, onRename, activeModelUri }: QueryTabsProps): JSX.Element {
const { allTabs } = useValues(multitabEditorLogic)
const containerRef = useRef<HTMLDivElement | null>(null)
const prevTabsCountRef = useRef(allTabs.length)
useEffect(() => {
if (allTabs.length > prevTabsCountRef.current) {
containerRef.current?.scrollTo({
left: containerRef.current.scrollWidth,
behavior: 'smooth',
})
}
prevTabsCountRef.current = allTabs.length
}, [allTabs])
return (
<>
<div
// height is hardcoded to match implicit height from tree view nav bar
className="flex flex-row overflow-auto hide-scrollbar h-[39px]"
ref={containerRef}
>
{models.map((model: QueryTab) => (
<QueryTabComponent
key={model.uri.path}
model={model}
onClear={models.length > 1 ? onClear : undefined}
onClick={onClick}
active={activeModelUri?.uri.path === model.uri.path}
onRename={onRename}
/>
))}
</div>
<LemonButton
className="rounded-none"
onClick={() => onAdd()}
icon={<IconPlus fontSize={14} />}
data-attr="sql-editor-new-tab-button"
/>
</>
)
}
interface QueryTabProps {
model: QueryTab
onClick: (model: QueryTab) => void
onClear?: (model: QueryTab, options?: { force?: boolean }) => void
active: boolean
onRename: (model: QueryTab, newName: string) => void
}
function QueryTabComponent({ model, active, onClear, onClick, onRename }: QueryTabProps): JSX.Element {
const [tabName, setTabName] = useState(() => model.name || NEW_QUERY)
const [isEditing, setIsEditing] = useState(false)
useEffect(() => {
setTabName(model.name || model.view?.name || NEW_QUERY)
}, [model.view?.name, model.name])
const handleRename = (): void => {
setIsEditing(false)
onRename(model, tabName)
}
return (
<div
onClick={() => onClick?.(model)}
className={clsx(
'deprecated-space-y-px p-1 flex border-b-2 flex-row items-center gap-1 hover:bg-surface-primary cursor-pointer',
active
? 'bg-surface-primary border-b-2 !border-brand-yellow'
: 'bg-surface-secondary border-transparent',
onClear ? 'pl-3 pr-2' : 'px-3'
)}
>
{isEditing ? (
<AutoTab
value={tabName}
onChange={(e) => setTabName(e.target.value)}
onBlur={handleRename}
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleRename()
} else if (e.key === 'Escape') {
setIsEditing(false)
}
}}
/>
) : (
<div
onDoubleClick={() => {
// disable editing views
if (model.view) {
return
}
setIsEditing(!isEditing)
}}
className="flex-grow text-left whitespace-pre"
>
{tabName}
</div>
)}
{onClear && (
<LemonButton
onClick={(e) => {
e.stopPropagation()
onClear(model, { force: !!e.shiftKey })
}}
size="xsmall"
icon={<IconX />}
/>
)}
</div>
)
}

View File

@@ -1,6 +1,5 @@
import { Monaco } from '@monaco-editor/react'
import { useActions, useValues } from 'kea'
import { router } from 'kea-router'
import type { editor as importedEditor } from 'monaco-editor'
import { useMemo } from 'react'
@@ -12,7 +11,6 @@ import { LemonButton } from 'lib/lemon-ui/LemonButton'
import { Link } from 'lib/lemon-ui/Link'
import { IconCancel } from 'lib/lemon-ui/icons'
import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
import { cn } from 'lib/utils/css-classes'
import { urls } from 'scenes/urls'
import { panelLayoutLogic } from '~/layout/panel-layout/panelLayoutLogic'
@@ -23,7 +21,6 @@ import { dataWarehouseViewsLogic } from '../saved_queries/dataWarehouseViewsLogi
import { OutputPane } from './OutputPane'
import { QueryHistoryModal } from './QueryHistoryModal'
import { QueryPane } from './QueryPane'
import { QueryTabs } from './QueryTabs'
import { FixErrorButton } from './components/FixErrorButton'
import { draftsLogic } from './draftsLogic'
import { editorSizingLogic } from './editorSizingLogic'
@@ -31,14 +28,14 @@ import { multitabEditorLogic } from './multitabEditorLogic'
interface QueryWindowProps {
onSetMonacoAndEditor: (monaco: Monaco, editor: importedEditor.IStandaloneCodeEditor) => void
tabId: string
}
export function QueryWindow({ onSetMonacoAndEditor }: QueryWindowProps): JSX.Element {
const codeEditorKey = `hogQLQueryEditor/${router.values.location.pathname}`
export function QueryWindow({ onSetMonacoAndEditor, tabId }: QueryWindowProps): JSX.Element {
const codeEditorKey = `hogql-editor-${tabId}`
const {
allTabs,
activeModelUri,
activeTab,
queryInput,
editingView,
editingInsight,
@@ -49,26 +46,13 @@ export function QueryWindow({ onSetMonacoAndEditor }: QueryWindowProps): JSX.Ele
currentDraft,
changesToSave,
inProgressViewEdits,
activeTab,
} = useValues(multitabEditorLogic)
const { activePanelIdentifier, isLayoutPanelVisible } = useValues(panelLayoutLogic)
const { activePanelIdentifier } = useValues(panelLayoutLogic)
const { setActivePanelIdentifier } = useActions(panelLayoutLogic)
const {
renameTab,
selectTab,
deleteTab,
createTab,
setQueryInput,
runQuery,
setError,
setMetadata,
setMetadataLoading,
saveAsView,
saveDraft,
updateView,
} = useActions(multitabEditorLogic)
const { setQueryInput, runQuery, setError, setMetadata, setMetadataLoading, saveAsView, saveDraft, updateView } =
useActions(multitabEditorLogic)
const { openHistoryModal } = useActions(multitabEditorLogic)
const { saveOrUpdateDraft } = useActions(draftsLogic)
@@ -103,7 +87,7 @@ export function QueryWindow({ onSetMonacoAndEditor }: QueryWindowProps): JSX.Ele
className="rounded-none"
icon={<IconSidebarClose />}
type="tertiary"
size="small"
size="xsmall"
/>
)
}
@@ -115,7 +99,7 @@ export function QueryWindow({ onSetMonacoAndEditor }: QueryWindowProps): JSX.Ele
className="rounded-none"
icon={<IconSidebarClose />}
type="tertiary"
size="small"
size="xsmall"
/>
)
}
@@ -125,22 +109,6 @@ export function QueryWindow({ onSetMonacoAndEditor }: QueryWindowProps): JSX.Ele
return (
<div className="flex flex-1 flex-col h-full overflow-hidden">
<div
className={cn(
'flex flex-row overflow-x-auto z-[var(--z-top-navigation)]',
activePanelIdentifier !== 'Database' && !isLayoutPanelVisible && 'rounded-tl'
)}
>
{renderSidebarButton()}
<QueryTabs
models={allTabs}
onClick={selectTab}
onClear={deleteTab}
onAdd={createTab}
onRename={renameTab}
activeModelUri={activeModelUri}
/>
</div>
{(editingView || editingInsight) && (
<div className="h-5 bg-warning-highlight">
<span className="pl-2 text-xs">
@@ -160,6 +128,7 @@ export function QueryWindow({ onSetMonacoAndEditor }: QueryWindowProps): JSX.Ele
</div>
)}
<div className="flex flex-row justify-start align-center w-full pl-2 pr-2 bg-white dark:bg-black border-b">
{renderSidebarButton()}
<RunButton />
<LemonDivider vertical />
{isDraft && featureFlags[FEATURE_FLAGS.EDITOR_DRAFTS] && (
@@ -230,7 +199,7 @@ export function QueryWindow({ onSetMonacoAndEditor }: QueryWindowProps): JSX.Ele
</LemonButton>
</>
)}
{editingView && !isDraft && activeModelUri && (
{editingView && !isDraft && activeTab && (
<>
{featureFlags[FEATURE_FLAGS.EDITOR_DRAFTS] && (
<LemonButton
@@ -238,7 +207,7 @@ export function QueryWindow({ onSetMonacoAndEditor }: QueryWindowProps): JSX.Ele
size="xsmall"
id="sql-editor-query-window-save-draft"
onClick={() => {
saveDraft(activeModelUri, queryInput, editingView.id)
saveDraft(activeTab, queryInput, editingView.id)
}}
>
Save draft
@@ -298,7 +267,7 @@ export function QueryWindow({ onSetMonacoAndEditor }: QueryWindowProps): JSX.Ele
</div>
<QueryPane
originalValue={originalQueryInput}
queryInput={suggestedQueryInput}
queryInput={suggestedQueryInput || queryInput}
sourceQuery={sourceQuery.source}
promptError={null}
onRun={runQuery}
@@ -328,7 +297,7 @@ export function QueryWindow({ onSetMonacoAndEditor }: QueryWindowProps): JSX.Ele
},
}}
/>
<InternalQueryWindow />
<InternalQueryWindow tabId={tabId} />
<QueryHistoryModal />
</div>
)
@@ -378,13 +347,13 @@ function RunButton(): JSX.Element {
)
}
function InternalQueryWindow(): JSX.Element | null {
const { cacheLoading } = useValues(multitabEditorLogic)
function InternalQueryWindow({ tabId }: { tabId: string }): JSX.Element | null {
const { finishedLoading } = useValues(multitabEditorLogic)
// NOTE: hacky way to avoid flicker loading
if (cacheLoading) {
if (finishedLoading) {
return null
}
return <OutputPane />
return <OutputPane tabId={tabId} />
}

View File

@@ -1,80 +0,0 @@
jest.mock('idb', () => ({ openDB: jest.fn() }))
const openDBMock = require('idb').openDB as jest.Mock
const loadDbModule = async (): Promise<any> => {
let db
await jest.isolateModulesAsync(async () => {
db = await import('./db')
})
return db
}
const createMockedDb = (): { get: jest.Mock; put: jest.Mock; delete: jest.Mock } => ({
get: jest.fn(),
put: jest.fn(),
delete: jest.fn(),
})
describe('SQL Editor IndexedDB wrapper', () => {
const STORE_NAME = 'query-tab-state'
const TEST_KEY = 'editor/tabs/2024-01-15'
const TEST_VALUE = 'SELECT * FROM events WHERE timestamp > now() - 7d'
let mockedDb: ReturnType<typeof createMockedDb>
let db: Awaited<ReturnType<typeof loadDbModule>>
beforeEach(async () => {
jest.clearAllMocks()
mockedDb = createMockedDb()
openDBMock.mockResolvedValue(mockedDb)
db = await loadDbModule()
})
describe('DB initialization', () => {
it('initialize the db when accessed', async () => {
await db.get(TEST_KEY)
expect(openDBMock).toHaveBeenCalled()
})
it('creates object store if missing during upgrade', async () => {
// this is just to make sure that when IndexedDB need to upgrade, that it creates the object store if missing
let upgradeCallback: ((context: any) => void) | undefined
openDBMock.mockImplementation((_name, _version, options) => {
upgradeCallback = options.upgrade
return Promise.resolve(mockedDb)
})
// simulate a new db load
db = await loadDbModule()
await db.get(TEST_KEY)
const upgradeContext = {
createObjectStore: jest.fn(),
objectStoreNames: { contains: () => false }, // store doesn't exist yet
}
if (!upgradeCallback) {
throw new Error('upgradeCallback was not set')
}
upgradeCallback(upgradeContext)
expect(upgradeContext.createObjectStore).toHaveBeenCalledWith(STORE_NAME)
})
})
describe('DB operations', () => {
it('get, set, and delete tab state from IndexedDB', async () => {
mockedDb.get.mockResolvedValue(TEST_VALUE)
const value = await db.get(TEST_KEY)
expect(value).toBe(TEST_VALUE)
expect(mockedDb.get).toHaveBeenCalledWith(STORE_NAME, TEST_KEY)
await db.set(TEST_KEY, TEST_VALUE)
expect(mockedDb.put).toHaveBeenCalledWith(STORE_NAME, TEST_VALUE, TEST_KEY)
await db.del(TEST_KEY)
expect(mockedDb.delete).toHaveBeenCalledWith(STORE_NAME, TEST_KEY)
})
it('throws IndexedDB error on failure', async () => {
mockedDb.get.mockRejectedValue(new Error('IndexedDB failure'))
await expect(db.get(TEST_KEY)).rejects.toThrow('IndexedDB failure')
})
})
})

View File

@@ -1,19 +0,0 @@
import { openDB } from 'idb'
const dbPromise = openDB('sql-editor', 1, {
upgrade: (db) => {
db.createObjectStore('query-tab-state')
},
})
export const get = async (key: string): Promise<string | null> => {
return (await dbPromise).get('query-tab-state', key)
}
export const set = async (key: string, val: string): Promise<void> => {
return void (await dbPromise).put('query-tab-state', val, key)
}
export const del = async (key: string): Promise<void> => {
return (await dbPromise).delete('query-tab-state', key)
}

View File

@@ -1,5 +1,5 @@
import Fuse from 'fuse.js'
import { actions, connect, kea, listeners, path, reducers, selectors } from 'kea'
import { actions, connect, kea, listeners, path, props, reducers, selectors } from 'kea'
import { router, urlToAction } from 'kea-router'
import { subscriptions } from 'kea-subscriptions'
import posthog from 'posthog-js'
@@ -9,6 +9,7 @@ import { LemonDialog, Tooltip } from '@posthog/lemon-ui'
import { LemonButton } from 'lib/lemon-ui/LemonButton'
import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
import { tabAwareScene } from 'lib/logic/scenes/tabAwareScene'
import { copyToClipboard } from 'lib/utils/copyToClipboard'
import { ProductIntentContext } from 'lib/utils/product-intents'
import { databaseTableListLogic } from 'scenes/data-management/database/databaseTableListLogic'
@@ -70,8 +71,14 @@ export const renderTableCount = (count: undefined | number): null | JSX.Element
)
}
export interface EditorSceneLogicProps {
tabId: string
}
export const editorSceneLogic = kea<editorSceneLogicType>([
path(['data-warehouse', 'editor', 'editorSceneLogic']),
props({} as EditorSceneLogicProps),
tabAwareScene(),
connect(() => ({
values: [
sceneLogic,
@@ -131,7 +138,7 @@ export const editorSceneLogic = kea<editorSceneLogicType>([
posthog.capture('ai_query_prompt_open')
},
})),
selectors(({ actions }) => ({
selectors(({ actions, props }) => ({
contents: [
(s) => [
s.relevantViews,
@@ -169,8 +176,8 @@ export const editorSceneLogic = kea<editorSceneLogicType>([
: null,
onClick: () => {
multitabEditorLogic({
key: `hogQLQueryEditor/${router.values.location.pathname}`,
}).actions.createTab(`SELECT * FROM ${table.name}`)
tabId: props.tabId,
}).actions.createTab('SELECT * FROM ' + table.name)
},
menuItems: [
{
@@ -246,11 +253,11 @@ export const editorSceneLogic = kea<editorSceneLogicType>([
const onClick = (): void => {
isManagedView
? multitabEditorLogic({
key: `hogQLQueryEditor/${router.values.location.pathname}`,
}).actions.createTab(`SELECT * FROM ${view.name}`)
tabId: props.tabId,
}).actions.createTab('SELECT * FROM ' + view.name)
: isSavedQuery
? multitabEditorLogic({
key: `hogQLQueryEditor/${router.values.location.pathname}`,
tabId: props.tabId,
}).actions.editView(view.query.query, view)
: null
}
@@ -261,7 +268,7 @@ export const editorSceneLogic = kea<editorSceneLogicType>([
label: 'Edit view definition',
onClick: () => {
multitabEditorLogic({
key: `hogQLQueryEditor/${router.values.location.pathname}`,
tabId: props.tabId,
}).actions.editView(view.query.query, view)
},
},
@@ -391,8 +398,8 @@ export const editorSceneLogic = kea<editorSceneLogicType>([
searchMatch: null,
onClick: () => {
multitabEditorLogic({
key: `hogQLQueryEditor/${router.values.location.pathname}`,
}).actions.createTab(`SELECT * FROM ${table.name}`)
tabId: props.tabId,
}).actions.createTab('SELECT * FROM ' + table.name)
},
menuItems: [
{
@@ -441,8 +448,8 @@ export const editorSceneLogic = kea<editorSceneLogicType>([
searchMatch: null,
onClick: () => {
multitabEditorLogic({
key: `hogQLQueryEditor/${router.values.location.pathname}`,
}).actions.createTab(`SELECT * FROM ${table.name}`)
tabId: props.tabId,
}).actions.createTab('SELECT * FROM ' + table.name)
},
menuItems: [
{

View File

@@ -1,108 +0,0 @@
import { set } from './db'
import { editorModelsStateKey } from './multitabEditorLogic'
jest.mock('posthog-js', () => ({ captureException: jest.fn() }))
jest.mock('./db', () => ({
get: jest.fn(),
set: jest.fn(),
del: jest.fn(),
}))
const TEST_EDITOR_ID = 'test-editor'
const TEST_QUERY = 'SELECT * FROM events'
const TEST_TAB_NAME = 'Test Tab'
const TEST_URI = 'file://tab1'
const getEditorKey = (editorId: string): string => editorModelsStateKey(editorId)
const createTestData = (): string => JSON.stringify([{ uri: TEST_URI, name: TEST_TAB_NAME, query: TEST_QUERY }])
describe('multitabEditorLogic Storage', () => {
beforeEach(() => {
localStorage.clear()
jest.clearAllMocks()
})
// happy path test
it('migrates data from localStorage to IndexedDB and removes from localStorage', async () => {
const key = getEditorKey(TEST_EDITOR_ID)
const data = createTestData()
localStorage.setItem(key, data)
const setMock = set as jest.Mock
setMock.mockResolvedValue(undefined)
const lsValue = localStorage.getItem(key)
if (lsValue) {
try {
await set(key, lsValue)
localStorage.removeItem(key)
} catch {
// in this case, the try always succeeds, so nothing is needed
}
}
expect(set).toHaveBeenCalledWith(key, data)
expect(localStorage.getItem(key)).toBeNull()
})
// protects existing data if IndexedDB migration fails
it('keeps data in localStorage when IndexedDB migration fails', async () => {
const key = getEditorKey(TEST_EDITOR_ID)
const data = createTestData()
localStorage.setItem(key, data)
const setMock = set as jest.Mock
setMock.mockRejectedValue(new Error('IndexedDB quota exceeded'))
const lsValue = localStorage.getItem(key)
if (lsValue) {
try {
await set(key, lsValue) // this will fail since IndexedDB has been mocked to fail
localStorage.removeItem(key)
} catch {}
}
expect(localStorage.getItem(key)).toBe(data)
})
// saves new data if IndexedDB fails
it('falls back to localStorage when IndexedDB write fails', async () => {
const key = getEditorKey(TEST_EDITOR_ID)
const data = createTestData()
const setMock = set as jest.Mock
setMock.mockRejectedValue(new Error('IndexedDB unavailable'))
try {
await set(key, data)
localStorage.removeItem(key)
} catch {
localStorage.setItem(key, data)
}
expect(set).toHaveBeenCalledWith(key, data)
expect(localStorage.getItem(key)).toBe(data)
})
// when a tab is deleted, the remaining tabs are saved to storage (IndexedDB)
it('updates storage with remaining tabs when a tab is deleted', async () => {
const key = getEditorKey(TEST_EDITOR_ID)
const initialData = JSON.stringify([
{ uri: 'file://tab1', name: 'Tab 1', query: 'SELECT * FROM events' },
{ uri: 'file://tab2', name: 'Tab 2', query: 'SELECT * FROM persons' },
])
// expected output when Tab 1 is deleted (just Tab 2)
const remainingData = JSON.stringify([{ uri: 'file://tab2', name: 'Tab 2', query: 'SELECT * FROM persons' }])
const setMock = set as jest.Mock
setMock.mockResolvedValue(undefined)
await set(key, initialData)
try {
await set(key, remainingData)
localStorage.removeItem(key)
} catch {
localStorage.setItem(key, remainingData)
}
expect(set).toHaveBeenCalledWith(key, remainingData)
expect(localStorage.getItem(key)).toBeNull()
})
})

View File

@@ -11,6 +11,8 @@ import { DropdownMenuGroup, DropdownMenuItem, DropdownMenuSeparator } from 'lib/
import { copyToClipboard } from 'lib/utils/copyToClipboard'
import { cn } from 'lib/utils/css-classes'
import { dataWarehouseSettingsLogic } from 'scenes/data-warehouse/settings/dataWarehouseSettingsLogic'
import { sceneLogic } from 'scenes/sceneLogic'
import { Scene } from 'scenes/sceneTypes'
import { urls } from 'scenes/urls'
import { SearchHighlightMultiple } from '~/layout/navigation-3000/components/SearchHighlight'
@@ -40,7 +42,10 @@ export const QueryDatabase = (): JSX.Element => {
toggleEditJoinModal,
setEditingDraft,
renameDraft,
openUnsavedQuery,
deleteUnsavedQuery,
} = useActions(queryDatabaseLogic)
const { activeTabId, activeSceneId } = useValues(sceneLogic)
const { deleteDataWarehouseSavedQuery } = useActions(dataWarehouseViewsLogic)
const { deleteJoin } = useActions(dataWarehouseSettingsLogic)
@@ -82,6 +87,10 @@ export const QueryDatabase = (): JSX.Element => {
if (item && item.record?.type === 'column') {
void copyToClipboard(item.record.columnName, item.record.columnName)
}
if (item && item.record?.type === 'unsaved-query') {
openUnsavedQuery(item.record)
}
}}
renderItem={(item) => {
// Check if item has search matches for highlighting
@@ -96,8 +105,9 @@ export const QueryDatabase = (): JSX.Element => {
<div className="flex flex-row gap-1 justify-between">
<span
className={cn(
['managed-views', 'views', 'sources', 'drafts'].includes(item.record?.type) &&
'font-bold',
['managed-views', 'views', 'sources', 'drafts', 'unsaved-folder'].includes(
item.record?.type
) && 'font-bold',
item.record?.type === 'column' && 'font-mono text-xs',
'truncate'
)}
@@ -148,10 +158,10 @@ export const QueryDatabase = (): JSX.Element => {
asChild
onClick={(e) => {
e.stopPropagation()
if (router.values.location.pathname.endsWith(urls.sqlEditor())) {
multitabEditorLogic({
key: `hogQLQueryEditor/${router.values.location.pathname}`,
}).actions.createTab(`SELECT * FROM ${item.name}`)
if (activeSceneId === Scene.SQLEditor && activeTabId) {
multitabEditorLogic({ tabId: activeTabId }).actions.createTab(
`SELECT * FROM ${item.name}`
)
} else {
router.actions.push(urls.sqlEditor(`SELECT * FROM ${item.name}`))
}
@@ -199,10 +209,11 @@ export const QueryDatabase = (): JSX.Element => {
asChild
onClick={(e) => {
e.stopPropagation()
if (router.values.location.pathname.endsWith(urls.sqlEditor())) {
multitabEditorLogic({
key: `hogQLQueryEditor/${router.values.location.pathname}`,
}).actions.editView(item.record?.view.query.query, item.record?.view)
if (activeSceneId === Scene.SQLEditor && activeTabId) {
multitabEditorLogic({ tabId: activeTabId }).actions.editView(
item.record?.view.query.query,
item.record?.view
)
} else {
router.actions.push(urls.sqlEditor(undefined, item.record?.view.id))
}
@@ -285,6 +296,35 @@ export const QueryDatabase = (): JSX.Element => {
}
}
if (item.record?.type === 'unsaved-query') {
return (
<DropdownMenuGroup>
<DropdownMenuItem
asChild
onClick={(e) => {
e.stopPropagation()
if (item.record) {
openUnsavedQuery(item.record)
}
}}
>
<ButtonPrimitive menuItem>Open</ButtonPrimitive>
</DropdownMenuItem>
<DropdownMenuItem
asChild
onClick={(e) => {
e.stopPropagation()
if (item.record) {
deleteUnsavedQuery(item.record)
}
}}
>
<ButtonPrimitive menuItem>Discard</ButtonPrimitive>
</DropdownMenuItem>
</DropdownMenuGroup>
)
}
if (item.record?.type === 'sources') {
// used to override default icon behavior
return null

View File

@@ -21,7 +21,7 @@ import { UpstreamGraph } from './graph/UpstreamGraph'
import { infoTabLogic } from './infoTabLogic'
interface QueryInfoProps {
codeEditorKey: string
tabId: string
}
function getMaterializationStatusMessage(
@@ -113,8 +113,8 @@ function getMaterializationDisabledReasons(
}
}
export function QueryInfo({ codeEditorKey }: QueryInfoProps): JSX.Element {
const { sourceTableItems } = useValues(infoTabLogic({ codeEditorKey: codeEditorKey }))
export function QueryInfo({ tabId }: QueryInfoProps): JSX.Element {
const { sourceTableItems } = useValues(infoTabLogic({ tabId }))
const { editingView, upstream, upstreamViewMode } = useValues(multitabEditorLogic)
const { runDataWarehouseSavedQuery, saveAsView, setUpstreamViewMode } = useActions(multitabEditorLogic)
const { featureFlags } = useValues(featureFlagLogic)
@@ -573,7 +573,7 @@ export function QueryInfo({ codeEditorKey }: QueryInfoProps): JSX.Element {
dataSource={upstream.nodes}
/>
) : (
<UpstreamGraph codeEditorKey={codeEditorKey} />
<UpstreamGraph tabId={tabId} />
)}
</>
)}

View File

@@ -17,7 +17,6 @@ import {
useReactFlow,
} from '@xyflow/react'
import { useActions, useValues } from 'kea'
import { router } from 'kea-router'
import React, { useEffect, useMemo, useState } from 'react'
import { IconArchive, IconPencil, IconTarget } from '@posthog/icons'
@@ -33,12 +32,13 @@ import { dataWarehouseViewsLogic } from '../../../saved_queries/dataWarehouseVie
import { multitabEditorLogic } from '../../multitabEditorLogic'
interface UpstreamGraphProps {
codeEditorKey: string
tabId: string
}
interface LineageNodeProps {
data: LineageNodeType & { isCurrentView?: boolean }
edges: { source: string; target: string }[]
tabId: string
}
const MAT_VIEW_HEIGHT = 92
@@ -53,9 +53,8 @@ const RANK_SEP = 160
const BRAND_YELLOW = '#f9bd2b'
function LineageNode({ data, edges }: LineageNodeProps): JSX.Element {
const codeEditorKey = `hogQLQueryEditor/${router.values.location.pathname}`
const { editView } = useActions(multitabEditorLogic({ key: codeEditorKey }))
function LineageNode({ data, edges, tabId }: LineageNodeProps): JSX.Element {
const { editView } = useActions(multitabEditorLogic({ tabId }))
const { dataWarehouseSavedQueries } = useValues(dataWarehouseViewsLogic)
const getNodeType = (type: string, lastRunAt?: string): string => {
@@ -134,8 +133,8 @@ function LineageNode({ data, edges }: LineageNodeProps): JSX.Element {
)
}
const getNodeTypes = (edges: { source: string; target: string }[]): NodeTypes => ({
lineageNode: (props) => <LineageNode {...props} edges={edges} />,
const getNodeTypes = (edges: { source: string; target: string }[], tabId: string): NodeTypes => ({
lineageNode: (props) => <LineageNode {...props} tabId={tabId} edges={edges} />,
})
const getLayoutedElements = (
@@ -192,8 +191,8 @@ const getLayoutedElements = (
return { nodes: layoutedNodes, edges: layoutedEdges }
}
function UpstreamGraphContent({ codeEditorKey }: UpstreamGraphProps): JSX.Element {
const { upstream, editingView } = useValues(multitabEditorLogic({ key: codeEditorKey }))
function UpstreamGraphContent({ tabId }: UpstreamGraphProps): JSX.Element {
const { upstream, editingView } = useValues(multitabEditorLogic({ tabId }))
const { fitView } = useReactFlow()
const { isDarkModeOn } = useValues(themeLogic)
@@ -208,7 +207,7 @@ function UpstreamGraphContent({ codeEditorKey }: UpstreamGraphProps): JSX.Elemen
return getLayoutedElements(upstream.nodes, upstream.edges, editingView?.name)
}, [upstream, editingView?.name])
const nodeTypes = useMemo(() => getNodeTypes(edges), [edges])
const nodeTypes = useMemo(() => getNodeTypes(edges, tabId), [edges, tabId])
const coloredEdges: Edge[] = edges.map((edge) => {
const isHighlighted =
@@ -277,11 +276,11 @@ function UpstreamGraphContent({ codeEditorKey }: UpstreamGraphProps): JSX.Elemen
)
}
export function UpstreamGraph({ codeEditorKey }: UpstreamGraphProps): JSX.Element {
export function UpstreamGraph({ tabId }: UpstreamGraphProps): JSX.Element {
return (
<div className="h-[500px] border border-border rounded-md overflow-hidden">
<ReactFlowProvider>
<UpstreamGraphContent codeEditorKey={codeEditorKey} />
<UpstreamGraphContent tabId={tabId} />
</ReactFlowProvider>
</div>
)

View File

@@ -15,16 +15,16 @@ export interface InfoTableRow {
}
export interface InfoTabLogicProps {
codeEditorKey: string
tabId: string
}
export const infoTabLogic = kea<infoTabLogicType>([
path(['data-warehouse', 'editor', 'sidebar', 'infoTabLogic']),
props({} as InfoTabLogicProps),
key((props) => props.codeEditorKey),
key((props) => props.tabId),
connect((props: InfoTabLogicProps) => ({
values: [
multitabEditorLogic({ key: props.codeEditorKey }),
multitabEditorLogic({ tabId: props.tabId }),
['metadata'],
databaseTableListLogic,
['posthogTablesMap', 'dataWarehouseTablesMap'],

View File

@@ -1,11 +1,14 @@
import Fuse from 'fuse.js'
import { actions, connect, events, kea, listeners, path, reducers, selectors } from 'kea'
import { loaders } from 'kea-loaders'
import { router } from 'kea-router'
import { subscriptions } from 'kea-subscriptions'
import { IconDatabase, IconDocument, IconPlug, IconPlus } from '@posthog/icons'
import { LemonMenuItem } from '@posthog/lemon-ui'
import { Spinner } from '@posthog/lemon-ui'
import api from 'lib/api'
import { TreeItem } from 'lib/components/DatabaseTableTree/DatabaseTableTree'
import { FEATURE_FLAGS } from 'lib/constants'
import { LemonTreeRef, TreeDataItem } from 'lib/lemon-ui/LemonTree/LemonTree'
@@ -13,6 +16,8 @@ import { FeatureFlagsSet, featureFlagLogic } from 'lib/logic/featureFlagLogic'
import { databaseTableListLogic } from 'scenes/data-management/database/databaseTableListLogic'
import { DataWarehouseSourceIcon, mapUrlToProvider } from 'scenes/data-warehouse/settings/DataWarehouseSourceIcon'
import { dataWarehouseSettingsLogic } from 'scenes/data-warehouse/settings/dataWarehouseSettingsLogic'
import { urls } from 'scenes/urls'
import { userLogic } from 'scenes/userLogic'
import { FuseSearchMatch } from '~/layout/navigation-3000/sidebars/utils'
import {
@@ -21,7 +26,7 @@ import {
DatabaseSchemaManagedViewTable,
DatabaseSchemaTable,
} from '~/queries/schema/schema-general'
import { DataWarehouseSavedQuery, DataWarehouseSavedQueryDraft, DataWarehouseViewLink } from '~/types'
import { DataWarehouseSavedQuery, DataWarehouseSavedQueryDraft, DataWarehouseViewLink, QueryTabState } from '~/types'
import { dataWarehouseJoinsLogic } from '../../external/dataWarehouseJoinsLogic'
import { dataWarehouseViewsLogic } from '../../saved_queries/dataWarehouseViewsLogic'
@@ -322,6 +327,8 @@ export const queryDatabaseLogic = kea<queryDatabaseLogicType>([
selectSourceTable: (tableName: string) => ({ tableName }),
setSyncMoreNoticeDismissed: (dismissed: boolean) => ({ dismissed }),
setEditingDraft: (draftId: string) => ({ draftId }),
openUnsavedQuery: (record: Record<string, any>) => ({ record }),
deleteUnsavedQuery: (record: Record<string, any>) => ({ record }),
}),
connect(() => ({
values: [
@@ -343,6 +350,8 @@ export const queryDatabaseLogic = kea<queryDatabaseLogicType>([
['drafts', 'draftsResponseLoading', 'hasMoreDrafts'],
featureFlagLogic,
['featureFlags'],
userLogic,
['user'],
],
actions: [
viewLinkLogic,
@@ -408,6 +417,50 @@ export const queryDatabaseLogic = kea<queryDatabaseLogicType>([
},
],
}),
loaders(({ values }) => ({
queryTabState: [
null as QueryTabState | null,
{
loadQueryTabState: async () => {
if (!values.user) {
return null
}
try {
return await api.queryTabState.user(values.user?.uuid)
} catch (e) {
console.error(e)
return null
}
},
deleteUnsavedQuery: async ({ record }) => {
const { queryTabState } = values
if (!values.user || !queryTabState || !queryTabState.state || !queryTabState.id) {
return null
}
try {
const { editorModelsStateKey } = queryTabState.state
const queries = JSON.parse(editorModelsStateKey)
const newState = {
...queryTabState,
state: {
...queryTabState.state,
editorModelsStateKey: JSON.stringify(
queries.filter((q: any) => q.name !== record.name && q.path !== record.path)
),
},
}
await api.queryTabState.update(queryTabState.id, newState)
return newState
} catch (e) {
console.error(e)
return queryTabState
}
},
},
],
})),
selectors(({ actions }) => ({
hasNonPosthogSources: [
(s) => [s.dataWarehouseTables],
@@ -603,6 +656,7 @@ export const queryDatabaseLogic = kea<queryDatabaseLogicType>([
s.draftsResponseLoading,
s.hasMoreDrafts,
s.featureFlags,
s.queryTabState,
],
(
posthogTables: DatabaseSchemaTable[],
@@ -614,7 +668,8 @@ export const queryDatabaseLogic = kea<queryDatabaseLogicType>([
drafts: DataWarehouseSavedQueryDraft[],
draftsResponseLoading: boolean,
hasMoreDrafts: boolean,
featureFlags: FeatureFlagsSet
featureFlags: FeatureFlagsSet,
queryTabState: QueryTabState | null
): TreeDataItem[] => {
const sourcesChildren: TreeDataItem[] = []
@@ -694,6 +749,25 @@ export const queryDatabaseLogic = kea<queryDatabaseLogicType>([
viewsChildren.sort((a, b) => a.name.localeCompare(b.name))
managedViewsChildren.sort((a, b) => a.name.localeCompare(b.name))
const states = queryTabState?.state?.editorModelsStateKey
const unsavedChildren: TreeDataItem[] = []
let i = 1
if (states) {
try {
for (const state of JSON.parse(states)) {
unsavedChildren.push({
id: `unsaved-${i++}`,
name: state.name || 'Unsaved query',
type: 'node',
icon: <IconDocument />,
record: { type: 'unsaved-query', ...state },
})
}
} catch {
// do nothing
}
}
const draftsChildren: TreeDataItem[] = []
if (featureFlags[FEATURE_FLAGS.EDITOR_DRAFTS]) {
@@ -739,6 +813,20 @@ export const queryDatabaseLogic = kea<queryDatabaseLogicType>([
...(featureFlags[FEATURE_FLAGS.EDITOR_DRAFTS]
? [createTopLevelFolderNode('drafts', draftsChildren, false)]
: []),
...(unsavedChildren.length > 0
? [
{
id: 'unsaved-folder',
name: 'Unsaved queries',
type: 'node',
icon: <IconDocument />,
record: {
type: 'unsaved-folder',
},
children: unsavedChildren,
} as TreeDataItem,
]
: []),
createTopLevelFolderNode('views', viewsChildren),
createTopLevelFolderNode('managed-views', managedViewsChildren),
]
@@ -850,6 +938,15 @@ export const queryDatabaseLogic = kea<queryDatabaseLogicType>([
viewLinkLogic.actions.selectSourceTable(tableName)
viewLinkLogic.actions.toggleJoinTableModal()
},
openUnsavedQuery: ({ record }) => {
if (record.insight) {
router.actions.push(urls.sqlEditor(undefined, undefined, record.insight.short_id))
} else if (record.view) {
router.actions.push(urls.sqlEditor(undefined, record.view.id))
} else {
router.actions.push(urls.sqlEditor(record.query))
}
},
})),
subscriptions({
posthogTables: (posthogTables: DatabaseSchemaTable[]) => {
@@ -873,6 +970,7 @@ export const queryDatabaseLogic = kea<queryDatabaseLogicType>([
if (values.featureFlags[FEATURE_FLAGS.EDITOR_DRAFTS]) {
actions.loadDrafts()
}
actions.loadQueryTabState()
},
})),
])

View File

@@ -133,7 +133,7 @@ export const newTabSceneLogic = kea<newTabSceneLogicType>([
const queryTree: ItemsGridItem[] = [
{
category: 'Create new insight',
types: [{ name: 'SQL editor', icon: <IconDatabase />, href: '/sql' }, ...newInsightItems],
types: [{ name: 'SQL query', icon: <IconDatabase />, href: '/sql' }, ...newInsightItems],
},
{
category: 'Create new ...',

View File

@@ -79,8 +79,7 @@
"prettier --write"
],
"{playwright,frontend,products,common,ee}/**/*.{js,jsx,mjs,ts,tsx}": [
"oxlint --react-plugin -D react/exhaustive-deps --deny-warnings",
"oxlint --fix --fix-suggestions --fix-dangerously --quiet -A react/exhaustive-deps",
"oxlint --fix --fix-suggestions --quiet",
"prettier --write"
],
"plugin-server/**/*.{js,jsx,mjs,ts,tsx}": [

View File

@@ -3,22 +3,12 @@ import { expect, test } from '../utils/playwright-test-base'
test.describe('SQL Editor', () => {
test.beforeEach(async ({ page }) => {
await page.goToMenuItem('sql-editor')
await page.locator('[data-attr=sql-editor-new-tab-button]').click()
})
test('See SQL Editor', async ({ page }) => {
await expect(page.locator('[data-attr=editor-scene]')).toBeVisible()
await expect(page.locator('[data-attr=sql-editor-source-empty-state]')).toBeVisible()
await expect(page.getByText('Untitled 1')).toBeVisible()
})
test('Create new query tab', async ({ page }) => {
await page.locator('[data-attr=sql-editor-new-tab-button]').click()
await expect(page.locator('[data-attr=sql-editor-new-tab-button]')).toBeVisible()
// two tabs
await expect(page.getByText('Untitled 1')).toBeVisible()
await expect(page.getByText('Untitled 2')).toBeVisible()
await expect(page.getByText('SQL query')).toBeVisible()
})
test('Add source link', async ({ page }) => {