mirror of
https://github.com/BillyOutlast/posthog.git
synced 2026-02-04 03:01:23 +01:00
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:
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 |
@@ -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 === '') {
|
||||
|
||||
@@ -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
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
})),
|
||||
])
|
||||
|
||||
@@ -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 ...',
|
||||
|
||||
@@ -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}": [
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
Reference in New Issue
Block a user