diff --git a/frontend/__snapshots__/scenes-app-data-warehouse-querytabs--default--dark.png b/frontend/__snapshots__/scenes-app-data-warehouse-querytabs--default--dark.png deleted file mode 100644 index b03dac2efd..0000000000 Binary files a/frontend/__snapshots__/scenes-app-data-warehouse-querytabs--default--dark.png and /dev/null differ diff --git a/frontend/__snapshots__/scenes-app-data-warehouse-querytabs--default--light.png b/frontend/__snapshots__/scenes-app-data-warehouse-querytabs--default--light.png deleted file mode 100644 index 980ad35853..0000000000 Binary files a/frontend/__snapshots__/scenes-app-data-warehouse-querytabs--default--light.png and /dev/null differ diff --git a/frontend/__snapshots__/scenes-app-data-warehouse-querytabs--no-active-tabs--dark.png b/frontend/__snapshots__/scenes-app-data-warehouse-querytabs--no-active-tabs--dark.png deleted file mode 100644 index 8b94040c49..0000000000 Binary files a/frontend/__snapshots__/scenes-app-data-warehouse-querytabs--no-active-tabs--dark.png and /dev/null differ diff --git a/frontend/__snapshots__/scenes-app-data-warehouse-querytabs--no-active-tabs--light.png b/frontend/__snapshots__/scenes-app-data-warehouse-querytabs--no-active-tabs--light.png deleted file mode 100644 index d75c0a23ea..0000000000 Binary files a/frontend/__snapshots__/scenes-app-data-warehouse-querytabs--no-active-tabs--light.png and /dev/null differ diff --git a/frontend/__snapshots__/scenes-app-data-warehouse-querytabs--single-tab--dark.png b/frontend/__snapshots__/scenes-app-data-warehouse-querytabs--single-tab--dark.png deleted file mode 100644 index 6d0cb61907..0000000000 Binary files a/frontend/__snapshots__/scenes-app-data-warehouse-querytabs--single-tab--dark.png and /dev/null differ diff --git a/frontend/__snapshots__/scenes-app-data-warehouse-querytabs--single-tab--light.png b/frontend/__snapshots__/scenes-app-data-warehouse-querytabs--single-tab--light.png deleted file mode 100644 index 8d8d3715b4..0000000000 Binary files a/frontend/__snapshots__/scenes-app-data-warehouse-querytabs--single-tab--light.png and /dev/null differ diff --git a/frontend/src/layout/panel-layout/ProjectTree/projectTreeLogic.tsx b/frontend/src/layout/panel-layout/ProjectTree/projectTreeLogic.tsx index 5031648d8a..007c01418c 100644 --- a/frontend/src/layout/panel-layout/ProjectTree/projectTreeLogic.tsx +++ b/frontend/src/layout/panel-layout/ProjectTree/projectTreeLogic.tsx @@ -820,10 +820,14 @@ export const projectTreeLogic = kea([ 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 === '') { diff --git a/frontend/src/scenes/data-warehouse/editor/AutoTab.tsx b/frontend/src/scenes/data-warehouse/editor/AutoTab.tsx deleted file mode 100644 index 503a7781f0..0000000000 --- a/frontend/src/scenes/data-warehouse/editor/AutoTab.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import React, { useEffect, useRef } from 'react' - -interface AutoTabProps { - value: string - onChange: React.ChangeEventHandler - onKeyDown?: React.KeyboardEventHandler - onBlur: React.FocusEventHandler - 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(null) - const spanRef = useRef(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): void => { - onChange(e) - } - - return ( -
- - -
- ) -} - -export default AutoTab diff --git a/frontend/src/scenes/data-warehouse/editor/EditorScene.tsx b/frontend/src/scenes/data-warehouse/editor/EditorScene.tsx index ac577f6d1d..110b18b00b 100644 --- a/frontend/src/scenes/data-warehouse/editor/EditorScene.tsx +++ b/frontend/src/scenes/data-warehouse/editor/EditorScene.tsx @@ -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 { - +
setMonacoAndEditor([monaco, editor]) } diff --git a/frontend/src/scenes/data-warehouse/editor/OutputPane.tsx b/frontend/src/scenes/data-warehouse/editor/OutputPane.tsx index d0b69598cf..0fa9e3b5a8 100644 --- a/frontend/src/scenes/data-warehouse/editor/OutputPane.tsx +++ b/frontend/src/scenes/data-warehouse/editor/OutputPane.tsx @@ -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>({}) @@ -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} />
-
- {response && !responseError ? : <>} -
+
{response && !responseError ? : <>}
{featureFlags[FEATURE_FLAGS.QUERY_EXECUTION_DETAILS] ? : }
@@ -750,7 +740,7 @@ const Content = ({ rows, isDarkModeOn, vizKey, - editorKey, + tabId, setSourceQuery, exportContext, saveAsInsight, @@ -792,7 +782,7 @@ const Content = ({ return (
- +
) diff --git a/frontend/src/scenes/data-warehouse/editor/QueryTabs.stories.tsx b/frontend/src/scenes/data-warehouse/editor/QueryTabs.stories.tsx deleted file mode 100644 index d4c463cc3e..0000000000 --- a/frontend/src/scenes/data-warehouse/editor/QueryTabs.stories.tsx +++ /dev/null @@ -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 -const meta: Meta = { - title: 'Scenes-App/Data Warehouse/QueryTabs', - component: QueryTabs, - parameters: { - layout: 'fullscreen', - viewMode: 'story', - }, - tags: ['autodocs'], -} -export default meta - -const Template: StoryFn = (args) => { - return ( - - - - ) -} - -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, -} diff --git a/frontend/src/scenes/data-warehouse/editor/QueryTabs.tsx b/frontend/src/scenes/data-warehouse/editor/QueryTabs.tsx deleted file mode 100644 index 86faede685..0000000000 --- a/frontend/src/scenes/data-warehouse/editor/QueryTabs.tsx +++ /dev/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(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 ( - <> -
- {models.map((model: QueryTab) => ( - 1 ? onClear : undefined} - onClick={onClick} - active={activeModelUri?.uri.path === model.uri.path} - onRename={onRename} - /> - ))} -
- onAdd()} - icon={} - 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 ( -
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 ? ( - setTabName(e.target.value)} - onBlur={handleRename} - autoFocus - onKeyDown={(e) => { - if (e.key === 'Enter') { - handleRename() - } else if (e.key === 'Escape') { - setIsEditing(false) - } - }} - /> - ) : ( -
{ - // disable editing views - if (model.view) { - return - } - setIsEditing(!isEditing) - }} - className="flex-grow text-left whitespace-pre" - > - {tabName} -
- )} - {onClear && ( - { - e.stopPropagation() - - onClear(model, { force: !!e.shiftKey }) - }} - size="xsmall" - icon={} - /> - )} -
- ) -} diff --git a/frontend/src/scenes/data-warehouse/editor/QueryWindow.tsx b/frontend/src/scenes/data-warehouse/editor/QueryWindow.tsx index 8cefd37eb7..f4aa53a2a6 100644 --- a/frontend/src/scenes/data-warehouse/editor/QueryWindow.tsx +++ b/frontend/src/scenes/data-warehouse/editor/QueryWindow.tsx @@ -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={} type="tertiary" - size="small" + size="xsmall" /> ) } @@ -115,7 +99,7 @@ export function QueryWindow({ onSetMonacoAndEditor }: QueryWindowProps): JSX.Ele className="rounded-none" icon={} type="tertiary" - size="small" + size="xsmall" /> ) } @@ -125,22 +109,6 @@ export function QueryWindow({ onSetMonacoAndEditor }: QueryWindowProps): JSX.Ele return (
-
- {renderSidebarButton()} - -
{(editingView || editingInsight) && (
@@ -160,6 +128,7 @@ export function QueryWindow({ onSetMonacoAndEditor }: QueryWindowProps): JSX.Ele
)}
+ {renderSidebarButton()} {isDraft && featureFlags[FEATURE_FLAGS.EDITOR_DRAFTS] && ( @@ -230,7 +199,7 @@ export function QueryWindow({ onSetMonacoAndEditor }: QueryWindowProps): JSX.Ele )} - {editingView && !isDraft && activeModelUri && ( + {editingView && !isDraft && activeTab && ( <> {featureFlags[FEATURE_FLAGS.EDITOR_DRAFTS] && ( { - saveDraft(activeModelUri, queryInput, editingView.id) + saveDraft(activeTab, queryInput, editingView.id) }} > Save draft @@ -298,7 +267,7 @@ export function QueryWindow({ onSetMonacoAndEditor }: QueryWindowProps): JSX.Ele
- +
) @@ -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 + return } diff --git a/frontend/src/scenes/data-warehouse/editor/db.test.ts b/frontend/src/scenes/data-warehouse/editor/db.test.ts deleted file mode 100644 index 12481ffb37..0000000000 --- a/frontend/src/scenes/data-warehouse/editor/db.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -jest.mock('idb', () => ({ openDB: jest.fn() })) -const openDBMock = require('idb').openDB as jest.Mock - -const loadDbModule = async (): Promise => { - 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 - let db: Awaited> - - 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') - }) - }) -}) diff --git a/frontend/src/scenes/data-warehouse/editor/db.ts b/frontend/src/scenes/data-warehouse/editor/db.ts deleted file mode 100644 index 589b621a57..0000000000 --- a/frontend/src/scenes/data-warehouse/editor/db.ts +++ /dev/null @@ -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 => { - return (await dbPromise).get('query-tab-state', key) -} - -export const set = async (key: string, val: string): Promise => { - return void (await dbPromise).put('query-tab-state', val, key) -} - -export const del = async (key: string): Promise => { - return (await dbPromise).delete('query-tab-state', key) -} diff --git a/frontend/src/scenes/data-warehouse/editor/editorSceneLogic.tsx b/frontend/src/scenes/data-warehouse/editor/editorSceneLogic.tsx index fcde97a914..9370009f43 100644 --- a/frontend/src/scenes/data-warehouse/editor/editorSceneLogic.tsx +++ b/frontend/src/scenes/data-warehouse/editor/editorSceneLogic.tsx @@ -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([ path(['data-warehouse', 'editor', 'editorSceneLogic']), + props({} as EditorSceneLogicProps), + tabAwareScene(), connect(() => ({ values: [ sceneLogic, @@ -131,7 +138,7 @@ export const editorSceneLogic = kea([ posthog.capture('ai_query_prompt_open') }, })), - selectors(({ actions }) => ({ + selectors(({ actions, props }) => ({ contents: [ (s) => [ s.relevantViews, @@ -169,8 +176,8 @@ export const editorSceneLogic = kea([ : 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([ 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([ 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([ 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([ 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: [ { diff --git a/frontend/src/scenes/data-warehouse/editor/multitabEditorLogic.test.ts b/frontend/src/scenes/data-warehouse/editor/multitabEditorLogic.test.ts deleted file mode 100644 index 8016424255..0000000000 --- a/frontend/src/scenes/data-warehouse/editor/multitabEditorLogic.test.ts +++ /dev/null @@ -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() - }) -}) diff --git a/frontend/src/scenes/data-warehouse/editor/multitabEditorLogic.tsx b/frontend/src/scenes/data-warehouse/editor/multitabEditorLogic.tsx index d93d359242..259487cb2a 100644 --- a/frontend/src/scenes/data-warehouse/editor/multitabEditorLogic.tsx +++ b/frontend/src/scenes/data-warehouse/editor/multitabEditorLogic.tsx @@ -1,7 +1,7 @@ import { Monaco } from '@monaco-editor/react' -import { actions, afterMount, connect, kea, key, listeners, path, props, propsChanged, reducers, selectors } from 'kea' +import { actions, beforeUnmount, connect, kea, listeners, path, props, propsChanged, reducers, selectors } from 'kea' import { loaders } from 'kea-loaders' -import { router, urlToAction } from 'kea-router' +import { router } from 'kea-router' import { subscriptions } from 'kea-subscriptions' import isEqual from 'lodash.isequal' import { Uri, editor } from 'monaco-editor' @@ -11,16 +11,19 @@ import { LemonDialog, LemonInput, lemonToast } from '@posthog/lemon-ui' import api from 'lib/api' import { LemonField } from 'lib/lemon-ui/LemonField' +import { tabAwareActionToUrl } from 'lib/logic/scenes/tabAwareActionToUrl' +import { tabAwareScene } from 'lib/logic/scenes/tabAwareScene' +import { tabAwareUrlToAction } from 'lib/logic/scenes/tabAwareUrlToAction' import { initModel } from 'lib/monaco/CodeEditor' import { codeEditorLogic } from 'lib/monaco/codeEditorLogic' import { removeUndefinedAndNull } from 'lib/utils' import { copyToClipboard } from 'lib/utils/copyToClipboard' import { insightsApi } from 'scenes/insights/utils/api' +import { Scene } from 'scenes/sceneTypes' import { urls } from 'scenes/urls' import { userLogic } from 'scenes/userLogic' import { dataNodeLogic } from '~/queries/nodes/DataNode/dataNodeLogic' -import { insightVizDataNodeKey } from '~/queries/nodes/InsightViz/InsightViz' import { queryExportContext } from '~/queries/query' import { DataVisualizationNode, @@ -30,24 +33,22 @@ import { NodeKind, } from '~/queries/schema/schema-general' import { + Breadcrumb, ChartDisplayType, DataWarehouseSavedQuery, DataWarehouseSavedQueryDraft, ExportContext, LineageGraph, QueryBasedInsightModel, - QueryTabState, } from '~/types' import { dataWarehouseViewsLogic } from '../saved_queries/dataWarehouseViewsLogic' -import { DATAWAREHOUSE_EDITOR_ITEM_ID, sizeOfInBytes } from '../utils' import { ViewEmptyState } from './ViewLoadingState' -import { get, set } from './db' import { draftsLogic } from './draftsLogic' import { editorSceneLogic } from './editorSceneLogic' import { fixSQLErrorsLogic } from './fixSQLErrorsLogic' import type { multitabEditorLogicType } from './multitabEditorLogicType' -import { OutputTab, outputPaneLogic } from './outputPaneLogic' +import { outputPaneLogic } from './outputPaneLogic' import { aiSuggestionOnAccept, aiSuggestionOnAcceptText, @@ -56,80 +57,13 @@ import { } from './suggestions/aiSuggestion' export interface MultitabEditorLogicProps { - key: string + tabId: string monaco?: Monaco | null editor?: editor.IStandaloneCodeEditor | null } -export const editorModelsStateKey = (key: string | number): string => `${key}/editorModelQueries` -export const activeModelStateKey = (key: string | number): string => `${key}/activeModelUri` -export const activeModelVariablesStateKey = (key: string | number): string => `${key}/activeModelVariables` - -export const deprecatedAllTabsStateKey = (key: string | number): string => - `data-warehouse.editor.multitabEditorLogic.${key}.allTabs` -export const allTabsStateKey = (key: string | number): string => `${key}/allTabs` - -export const deprecatedInProgressViewEditStateKey = (key: string | number): string => - `data-warehouse.editor.multitabEditorLogic.${key}.inProgressViewEdits` -export const inProgressViewEditStateKey = (key: string | number): string => `${key}/inProgressViewEdits` - -export const modelViewStatesKey = (key: string | number): string => `${key}/modelViewStates` - export const NEW_QUERY = 'Untitled' -const getNextUntitledNumber = (tabs: QueryTab[]): number => { - const untitledNumbers = tabs - .filter((tab) => tab.name?.startsWith(NEW_QUERY)) - .map((tab) => { - const match = tab.name?.match(/Untitled (\d+)/) - return match ? parseInt(match[1]) : 0 - }) - .filter((num) => !isNaN(num)) - - if (untitledNumbers.length === 0) { - return 1 - } - - // Find the first gap in the sequence or use the next number - for (let i = 1; i <= untitledNumbers.length + 1; i++) { - if (!untitledNumbers.includes(i)) { - return i - } - } - return untitledNumbers.length + 1 -} - -const getStorageItem = async (key: string, newKey?: string): Promise => { - // If we're migrating from a deprecated key, we need to get the value from the old key and set it to the new key - - const dbValue = await get(key) - - if (dbValue) { - return dbValue - } - - if (newKey) { - const newKeyDbValue = await get(newKey) - if (newKeyDbValue) { - return newKeyDbValue - } - } - - const lsValue = localStorage.getItem(key) - - if (lsValue) { - await set(newKey || key, lsValue) - localStorage.removeItem(key) - return lsValue - } - - return null -} - -const setStorageItem = async (key: string, value: string): Promise => { - await set(key, value) -} - export interface QueryTab { uri: Uri view?: DataWarehouseSavedQuery @@ -169,10 +103,27 @@ export type UpdateViewPayload = Partial & { types: string[][] } +function getTabHash(values: multitabEditorLogicType['values']): Record { + const hash: Record = { + q: values.queryInput, + } + if (values.activeTab.view) { + hash['view'] = values.activeTab.view.id + } + if (values.activeTab.insight) { + hash['insight'] = values.activeTab.insight.short_id + } + if (values.activeTab.draft) { + hash['draft'] = values.activeTab.draft.id + } + + return hash +} + export const multitabEditorLogic = kea([ path(['data-warehouse', 'editor', 'multitabEditorLogic']), props({} as MultitabEditorLogicProps), - key((props) => props.key), + tabAwareScene(), connect(() => ({ values: [ dataWarehouseViewsLogic, @@ -205,17 +156,15 @@ export const multitabEditorLogic = kea([ ['saveAsDraft', 'deleteDraft', 'saveAsDraftSuccess', 'deleteDraftSuccess'], ], })), - actions(({ values }) => ({ + actions(() => ({ setQueryInput: (queryInput: string) => ({ queryInput }), - updateState: (skipBreakpoint?: boolean) => ({ skipBreakpoint }), runQuery: (queryOverride?: string, switchTab?: boolean) => ({ queryOverride, switchTab, }), setActiveQuery: (query: string) => ({ query }), - renameTab: (tab: QueryTab, newName: string) => ({ tab, newName }), + setTabs: (tabs: QueryTab[]) => ({ tabs }), - addTab: (tab: QueryTab) => ({ tab }), createTab: ( query?: string, view?: DataWarehouseSavedQuery, @@ -227,15 +176,10 @@ export const multitabEditorLogic = kea([ insight, draft, }), - loadUpstream: (modelId: string) => ({ modelId }), - deleteTab: (tab: QueryTab, options?: { force?: boolean }) => ({ tab, options }), - _deleteTab: (tab: QueryTab) => ({ tab }), - removeTab: (tab: QueryTab) => ({ tab }), - selectTab: (tab: QueryTab) => ({ tab }), - _selectTab: (tab: QueryTab) => ({ tab }), updateTab: (tab: QueryTab) => ({ tab }), - setLocalState: (key: string, value: any) => ({ key, value }), + initialize: true, + loadUpstream: (modelId: string) => ({ modelId }), saveAsView: (materializeAfterSave = false, fromDraft?: string) => ({ fromDraft, materializeAfterSave }), saveAsViewSubmit: (name: string, materializeAfterSave = false, fromDraft?: string) => ({ fromDraft, @@ -245,7 +189,7 @@ export const multitabEditorLogic = kea([ saveAsInsight: true, saveAsInsightSubmit: (name: string) => ({ name }), updateInsight: true, - setCacheLoading: (loading: boolean) => ({ loading }), + setFinishedLoading: (loading: boolean) => ({ loading }), setError: (error: string | null) => ({ error }), setDataError: (error: string | null) => ({ error }), setSourceQuery: (sourceQuery: DataVisualizationNode) => ({ sourceQuery }), @@ -253,7 +197,6 @@ export const multitabEditorLogic = kea([ setMetadataLoading: (loading: boolean) => ({ loading }), editView: (query: string, view: DataWarehouseSavedQuery) => ({ query, view }), editInsight: (query: string, insight: QueryBasedInsightModel) => ({ query, insight }), - updateQueryTabState: (skipBreakpoint?: boolean) => ({ skipBreakpoint }), setLastRunQuery: (lastRunQuery: DataVisualizationNode | null) => ({ lastRunQuery }), _setSuggestionPayload: (payload: SuggestionPayload | null) => ({ payload }), setSuggestedQueryInput: (suggestedQueryInput: string, source?: SuggestionPayload['source']) => ({ @@ -262,7 +205,6 @@ export const multitabEditorLogic = kea([ }), onAcceptSuggestedQueryInput: (shouldRunQuery?: boolean) => ({ shouldRunQuery }), onRejectSuggestedQueryInput: true, - setResponse: (response: Record | null) => ({ response, currentTab: values.activeModelUri }), shareTab: true, openHistoryModal: true, closeHistoryModal: true, @@ -292,53 +234,21 @@ export const multitabEditorLogic = kea([ actions.initialize() } }), - loaders(({ values, props }) => ({ - queryTabState: [ - null as QueryTabState | null, - { - loadQueryTabState: async () => { - if (!values.user) { - return null - } - let queryTabStateModel = null - try { - queryTabStateModel = await api.queryTabState.user(values.user?.uuid) - } catch (e) { - console.error(e) - } - - const localEditorModels = await getStorageItem(editorModelsStateKey(props.key)) - const localActiveModelUri = await getStorageItem(activeModelStateKey(props.key)) - - if (queryTabStateModel === null) { - queryTabStateModel = await api.queryTabState.create({ - state: { - editorModelsStateKey: localEditorModels || '', - activeModelStateKey: localActiveModelUri || '', - sourceQuery: values.sourceQuery ? JSON.stringify(values.sourceQuery) : '', - }, - }) - } - - return queryTabStateModel - }, - }, - ], + loaders(() => ({ upstream: [ null as LineageGraph | null, { loadUpstream: async (payload: { modelId: string }) => { - const upstream = await api.upstream.get(payload.modelId) - return upstream + return await api.upstream.get(payload.modelId) }, }, ], })), reducers(({ props }) => ({ - cacheLoading: [ + finishedLoading: [ true, { - setCacheLoading: (_, { loading }) => loading, + setFinishedLoading: (_, { loading }) => loading, }, ], sourceQuery: [ @@ -372,39 +282,17 @@ export const multitabEditorLogic = kea([ setActiveQuery: (_, { query }) => query, }, ], - activeModelUri: [ - null as QueryTab | null, - { - _selectTab: (_, { tab }) => tab, - }, - ], editingInsight: [ null as QueryBasedInsightModel | null, { - _selectTab: (_, { tab }) => tab.insight ?? null, + updateTab: (_, { tab }) => tab.insight ?? null, }, ], allTabs: [ [] as QueryTab[], { - addTab: (state, { tab }) => { - return [...state, tab] - }, - removeTab: (state, { tab: tabToRemove }) => { - return state.filter((tab) => tab.uri.toString() !== tabToRemove.uri.toString()) - }, + updateTab: (_, { tab }) => [tab], setTabs: (_, { tabs }) => tabs, - updateTab: (state, { tab }) => { - return state.map((stateTab) => { - if (stateTab.uri.path === tab.uri.path) { - return { - ...stateTab, - ...tab, - } - } - return stateTab - }) - }, }, ], error: [ @@ -425,7 +313,7 @@ export const multitabEditorLogic = kea([ setMetadata: (_, { metadata }) => metadata, }, ], - editorKey: [props.key], + editorKey: [`hogql-editor-${props.tabId}`, {}], suggestionPayload: [ null as SuggestionPayload | null, { @@ -490,7 +378,7 @@ export const multitabEditorLogic = kea([ }, ], })), - listeners(({ values, props, actions, asyncActions }) => ({ + listeners(({ values, props, actions, asyncActions, cache }) => ({ fixErrorsSuccess: ({ response }) => { actions.setSuggestedQueryInput(response.query, 'hogql_fixer') @@ -500,7 +388,7 @@ export const multitabEditorLogic = kea([ posthog.capture('ai-error-fixer-failure') }, shareTab: () => { - const currentTab = values.activeModelUri + const currentTab = values.activeTab if (!currentTab) { return } @@ -538,7 +426,7 @@ export const multitabEditorLogic = kea([ }, setSuggestedQueryInput: ({ suggestedQueryInput, source }) => { // If there's no active tab, create one first to ensure Monaco Editor is available - if (!values.activeModelUri || values.allTabs.length === 0) { + if (!values.activeTab) { actions.createTab(suggestedQueryInput) return } @@ -561,24 +449,23 @@ export const multitabEditorLogic = kea([ values.suggestionPayload?.onAccept(!!shouldRunQuery, actions, values, props) // Re-create the model to prevent it from being purged - if (props.monaco && values.activeModelUri) { - const existingModel = props.monaco.editor.getModel(values.activeModelUri.uri) + if (props.monaco && values.activeTab) { + const existingModel = props.monaco.editor.getModel(values.activeTab.uri) if (!existingModel) { const newModel = props.monaco.editor.createModel( values.suggestedQueryInput, 'hogQL', - values.activeModelUri.uri + values.activeTab.uri ) - const mountedCodeEditorLogic = - codeEditorLogic.findMounted() || + initModel( + newModel, codeEditorLogic({ - key: props.key, + key: `hogql-editor-${props.tabId}`, query: values.suggestedQueryInput, language: 'hogQL', }) - - initModel(newModel, mountedCodeEditorLogic) + ) props.editor?.setModel(newModel) } else { props.editor?.setModel(existingModel) @@ -586,30 +473,23 @@ export const multitabEditorLogic = kea([ } posthog.capture('sql-editor-accepted-suggestion', { source: values.suggestedSource }) actions._setSuggestionPayload(null) - actions.updateState(true) }, onRejectSuggestedQueryInput: () => { values.suggestionPayload?.onReject(actions, values, props) // Re-create the model to prevent it from being purged - if (props.monaco && values.activeModelUri) { - const existingModel = props.monaco.editor.getModel(values.activeModelUri.uri) + if (props.monaco && values.activeTab) { + const existingModel = props.monaco.editor.getModel(values.activeTab.uri) if (!existingModel) { - const newModel = props.monaco.editor.createModel( - values.queryInput, - 'hogQL', - values.activeModelUri.uri - ) - - const mountedCodeEditorLogic = - codeEditorLogic.findMounted() || + const newModel = props.monaco.editor.createModel(values.queryInput, 'hogQL', values.activeTab.uri) + initModel( + newModel, codeEditorLogic({ - key: props.key, + key: `hogql-editor-${props.tabId}`, query: values.queryInput, language: 'hogQL', }) - - initModel(newModel, mountedCodeEditorLogic) + ) props.editor?.setModel(newModel) } else { props.editor?.setModel(existingModel) @@ -617,55 +497,34 @@ export const multitabEditorLogic = kea([ } posthog.capture('sql-editor-rejected-suggestion', { source: values.suggestedSource }) actions._setSuggestionPayload(null) - actions.updateState(true) }, editView: ({ query, view }) => { - const maybeExistingTab = values.allTabs.find((tab) => tab.view?.id === view.id) - if (maybeExistingTab) { - actions._selectTab(maybeExistingTab) - } else { - actions.createTab(query, view) - } + actions.createTab(query, view) }, editInsight: ({ query, insight }) => { - const maybeExistingTab = values.allTabs.find((tab) => tab.insight?.short_id === insight.short_id) - - if (maybeExistingTab) { - const updatedTab = { ...maybeExistingTab, insight } - actions.updateTab(updatedTab) - actions._selectTab(updatedTab) - } else { - actions.createTab(query, undefined, insight) - } + actions.createTab(query, undefined, insight) }, createTab: async ({ query = '', view, insight, draft }) => { - const mountedCodeEditorLogic = - codeEditorLogic.findMounted() || - codeEditorLogic({ - key: props.key, - query: values.sourceQuery?.source.query ?? '', - language: 'hogQL', - }) - - let currentModelCount = 1 - const allNumbers = values.allTabs.map((tab) => parseInt(tab.uri.path.split('/').pop() || '0')) - while (allNumbers.includes(currentModelCount)) { - currentModelCount++ - } - - const nextUntitledNumber = getNextUntitledNumber(values.allTabs) - const tabName = draft?.name || view?.name || insight?.name || `${NEW_QUERY} ${nextUntitledNumber}` + const currentModelCount = 1 + const tabName = draft?.name || view?.name || insight?.name || NEW_QUERY if (props.monaco) { const uri = props.monaco.Uri.parse(currentModelCount.toString()) - const model = props.monaco.editor.createModel(query, 'hogQL', uri) - props.editor?.setModel(model) - - if (mountedCodeEditorLogic) { - initModel(model, mountedCodeEditorLogic) + let model = props.monaco.editor.getModel(uri) + if (!model) { + model = props.monaco.editor.createModel(query, 'hogQL', uri) + props.editor?.setModel(model) + initModel( + model, + codeEditorLogic({ + key: `hogql-editor-${props.tabId}`, + query: values.sourceQuery?.source.query ?? '', + language: 'hogQL', + }) + ) } - actions.addTab({ + actions.updateTab({ uri, view, insight, @@ -673,114 +532,26 @@ export const multitabEditorLogic = kea([ sourceQuery: insight?.query as DataVisualizationNode | undefined, draft: draft, }) - actions._selectTab({ - uri, - view, - insight, - name: tabName, - sourceQuery: insight?.query as DataVisualizationNode | undefined, - draft: draft, - }) - - const queries = values.allTabs.map((tab) => { - return { - query: props.monaco?.editor.getModel(tab.uri)?.getValue() || '', - path: tab.uri.path.split('/').pop(), - view: uri.path === tab.uri.path ? view : tab.view, - insight: uri.path === tab.uri.path ? insight : tab.insight, - sourceQuery: uri.path === tab.uri.path ? insight?.query : tab.insight?.query, - name: tab.name, - response: tab.response, - draft: tab.draft, - } - }) - actions.setLocalState(editorModelsStateKey(props.key), JSON.stringify(queries)) - } else if (query) { - // if navigating from URL without monaco loaded - const queries = [ - ...values.allTabs, - { - query, - path: currentModelCount.toString(), - view, - insight, - name: tabName, - sourceQuery: insight?.query as DataVisualizationNode | undefined, - draft: draft, - }, - ] - actions.setLocalState(editorModelsStateKey(props.key), JSON.stringify(queries)) - actions.setLocalState(activeModelStateKey(props.key), currentModelCount.toString()) } - }, - renameTab: ({ tab, newName }) => { - const updatedTabs = values.allTabs.map((t) => { - if (t.uri.toString() === tab.uri.toString()) { - return { - ...t, - name: newName, - } + if (query) { + actions.setQueryInput(query) + } else if (draft) { + actions.setQueryInput(draft.query.query) + } else if (view) { + actions.setQueryInput(view.query.query) + } else if (insight) { + const queryObject = (insight.query as DataVisualizationNode | null)?.source || insight.query + if (queryObject && 'query' in queryObject) { + actions.setQueryInput(queryObject.query || '') } - return t - }) - actions.setTabs(updatedTabs) - const activeTab = updatedTabs.find((t) => t.uri.toString() === tab.uri.toString()) - if (activeTab) { - actions._selectTab(activeTab) - } - actions.updateState() - }, - selectTab: async ({ tab }) => { - if (props.editor && values.activeModelUri) { - const viewState = props.editor.saveViewState() - const modelViewStates = await getStorageItem(modelViewStatesKey(props.key)) - const modelViewStatesParsed = - modelViewStates && modelViewStates !== 'undefined' ? JSON.parse(modelViewStates) : {} - actions.setLocalState( - modelViewStatesKey(props.key), - JSON.stringify({ - ...modelViewStatesParsed, - [values.activeModelUri.uri.path]: viewState, - }) - ) - } - - actions._selectTab(tab) - }, - _selectTab: async ({ tab }) => { - if (props.monaco) { - const model = props.monaco.editor.getModel(tab.uri) - props.editor?.setModel(model) - - if (props.editor) { - const modelViewStates = await getStorageItem(modelViewStatesKey(props.key)) - const modelViewStatesParsed = - modelViewStates && modelViewStates !== 'undefined' ? JSON.parse(modelViewStates) : {} - const viewState = modelViewStatesParsed[tab.uri.path] - if (viewState) { - props.editor.restoreViewState(viewState) - } - } - } - - const path = tab.uri.path.split('/').pop() - if (path) { - actions.setLocalState(activeModelStateKey(props.key), path) - actions.updateQueryTabState() - } - - if (tab.insight) { - actions.setActiveTab(OutputTab.Visualization) } }, setSourceQuery: ({ sourceQuery }) => { - if (!values.activeModelUri) { + if (!values.activeTab) { return } - const currentTab = values.allTabs.find( - (tab) => tab.uri.toString() === values.activeModelUri?.uri.toString() - ) + const currentTab = values.activeTab if (currentTab) { actions.updateTab({ ...currentTab, @@ -788,205 +559,24 @@ export const multitabEditorLogic = kea([ }) } }, - deleteTab: ({ tab: tabToRemove, options: { force } = {} }) => { - if (force) { - actions._deleteTab(tabToRemove) - return - } - - if ( - (values.activeModelUri?.view && values.queryInput !== values.sourceQuery.source.query) || - (values.activeModelUri?.draft && values.queryInput !== tabToRemove.draft?.query.query) - ) { - const viewOrDraft = values.activeModelUri?.draft ? 'draft' : 'view' - - LemonDialog.open({ - title: 'Close tab', - description: `Are you sure you want to close this ${viewOrDraft}? There are unsaved changes.`, - primaryButton: { - children: 'Close without saving', - status: 'danger', - onClick: () => actions._deleteTab(tabToRemove), - }, - }) - } else if (values.updateInsightButtonEnabled) { - LemonDialog.open({ - title: 'Close insight', - description: 'Are you sure you want to close this insight? There are unsaved changes.', - primaryButton: { - children: 'Close without saving', - status: 'danger', - onClick: () => actions._deleteTab(tabToRemove), - }, - }) - } else if ( - values.queryInput !== '' && - !values.activeModelUri?.view && - !values.activeModelUri?.insight && - !values.activeModelUri?.draft - ) { - LemonDialog.open({ - title: 'Unsaved query', - description: - "You're about to close a tab with an unsaved query. If you continue, your changes will be permanently lost.", - primaryButton: { - children: 'Close without saving', - status: 'danger', - onClick: () => actions._deleteTab(tabToRemove), - }, - }) - } else { - actions._deleteTab(tabToRemove) - } - }, - _deleteTab: ({ tab: tabToRemove }) => { - if (!props.monaco) { - return - } - - const model = props.monaco.editor.getModel(tabToRemove.uri) - if (tabToRemove.uri.toString() === values.activeModelUri?.uri.toString()) { - const indexOfModel = values.allTabs.findIndex( - (tab) => tab.uri.toString() === tabToRemove.uri.toString() - ) - const nextModel = - values.allTabs[indexOfModel + 1] || values.allTabs[indexOfModel - 1] || values.allTabs[0] // there will always be one - actions._selectTab(nextModel) - } - model?.dispose() - actions.removeTab(tabToRemove) - const queries = values.allTabs.map((tab) => { - return { - query: props.monaco?.editor.getModel(tab.uri)?.getValue() || '', - path: tab.uri.path.split('/').pop(), - view: tab.view, - insight: tab.insight, - response: tab.response, - } - }) - actions.setLocalState(editorModelsStateKey(props.key), JSON.stringify(queries)) - }, - setLocalState: async ({ key, value }) => { - await setStorageItem(key, value) - }, initialize: async () => { - // TODO: replace with queryTabState - const allModelQueries = await getStorageItem(editorModelsStateKey(props.key)) - const activeModelUri = await getStorageItem(activeModelStateKey(props.key)) - const allTabs = await getStorageItem(deprecatedAllTabsStateKey(props.key), allTabsStateKey(props.key)) - const inProgressViewEdit = await getStorageItem( - deprecatedInProgressViewEditStateKey(props.key), - inProgressViewEditStateKey(props.key) - ) - - const allTabsParsed = allTabs && allTabs !== 'undefined' ? JSON.parse(allTabs) : [] - const inProgressViewEditParsed = - inProgressViewEdit && inProgressViewEdit !== 'undefined' ? JSON.parse(inProgressViewEdit) : {} - actions.setInProgressViewEdits(inProgressViewEditParsed) - - const mountedCodeEditorLogic = - codeEditorLogic.findMounted() || - codeEditorLogic({ - key: props.key, - query: values.sourceQuery?.source.query ?? '', - language: 'hogQL', - }) - - if (allModelQueries) { - // clear existing models - props.monaco?.editor.getModels().forEach((model: editor.ITextModel) => { - model.dispose() - }) - - const models = JSON.parse(allModelQueries || '[]') - const newModels: QueryTab[] = [] - - models.forEach((model: Record) => { - if (props.monaco) { - const uri = props.monaco.Uri.parse(model.path) - const newModel = props.monaco.editor.createModel(model.query, 'hogQL', uri) - props.editor?.setModel(newModel) - - const existingTab = allTabsParsed.find((tab: QueryTab) => tab.uri.path === uri.path) - - newModels.push({ - uri, - view: model.view, - insight: model.insight, - name: model.name, - sourceQuery: existingTab?.sourceQuery, - response: model.response, - draft: model.draft, - }) - mountedCodeEditorLogic && initModel(newModel, mountedCodeEditorLogic) - } - }) - - actions.setTabs(newModels) - - if (activeModelUri && newModels.length) { - const uri = props.monaco?.Uri.parse(activeModelUri) - const activeModel = props.monaco?.editor - .getModels() - .find((model: editor.ITextModel) => model.uri.path === uri?.path) - activeModel && props.editor?.setModel(activeModel) - const val = activeModel?.getValue() - - if (val) { - actions.setQueryInput(val) - } - - const activeTab = newModels.find((tab) => tab.uri.path.split('/').pop() === activeModelUri) - const activeView = activeTab?.view - const activeInsight = activeTab?.insight - - if (uri && activeTab) { - actions._selectTab({ - uri, - view: activeView, - name: activeTab.draft?.name || activeView?.name || activeInsight?.name || activeTab.name, - insight: activeInsight, - sourceQuery: activeTab.sourceQuery, - response: activeTab.response, - draft: activeTab.draft, - }) - } - } else if (newModels.length) { - actions.selectTab({ - uri: newModels[0].uri, - name: newModels[0].view?.name || newModels[0].insight?.name || newModels[0].name, - sourceQuery: newModels[0].sourceQuery, - view: newModels[0].view, - insight: newModels[0].insight, - response: newModels[0].response, - draft: newModels[0].draft, - }) - } - } else { - const model = props.editor?.getModel() - - if (model) { - actions.createTab() - } + if (!values.activeTab) { + actions.createTab() } - actions.setCacheLoading(false) + actions.setFinishedLoading(false) }, setQueryInput: ({ queryInput }) => { // if editing a view, track latest history id changes are based on - if (values.activeModelUri?.view && values.activeModelUri?.view.query?.query) { - if (queryInput === values.activeModelUri.view?.query.query) { - actions.deleteInProgressViewEdit(values.activeModelUri.view.id) + if (values.activeTab?.view && values.activeTab?.view.query?.query) { + if (queryInput === values.activeTab.view?.query.query) { + actions.deleteInProgressViewEdit(values.activeTab.view.id) } else if ( - !values.inProgressViewEdits[values.activeModelUri.view.id] && - values.activeModelUri.view.latest_history_id + !values.inProgressViewEdits[values.activeTab.view.id] && + values.activeTab.view.latest_history_id ) { - actions.setInProgressViewEdit( - values.activeModelUri.view.id, - values.activeModelUri.view.latest_history_id - ) + actions.setInProgressViewEdit(values.activeTab.view.id, values.activeTab.view.latest_history_id) } } - actions.updateState() }, saveDraft: async ({ activeTab, queryInput, viewId }) => { const latestActiveTab = values.allTabs.find((tab) => tab.uri.toString() === activeTab.uri.toString()) @@ -1003,36 +593,7 @@ export const multitabEditorLogic = kea([ } }, saveAsDraftSuccess: ({ draft, tab: tabToUpdate }) => { - const newTabs = values.allTabs.map((tab) => { - if (tab.uri.toString() === tabToUpdate.uri.toString()) { - return { ...tab, name: draft.name, draft: draft } - } - return tab - }) - actions.setTabs(newTabs) - actions.updateState() - }, - updateState: async ({ skipBreakpoint }, breakpoint) => { - if (skipBreakpoint !== true) { - await breakpoint(100) - } - - const queries = values.allTabs.map((model) => { - return { - query: props.monaco?.editor.getModel(model.uri)?.getValue() || '', - path: model.uri.path.split('/').pop(), - name: model.view?.name || model.name, - view: model.view, - insight: model.insight, - response: model.response, - draft: model.draft, - } - }) - actions.setLocalState(editorModelsStateKey(props.key), JSON.stringify(queries)) - actions.updateQueryTabState(skipBreakpoint) - - actions.setLocalState(allTabsStateKey(props.key), JSON.stringify(values.allTabs)) - actions.setLocalState(inProgressViewEditStateKey(props.key), JSON.stringify(values.inProgressViewEdits)) + actions.updateTab({ ...tabToUpdate, name: draft.name, draft: draft }) }, runQuery: ({ queryOverride, switchTab }) => { const query = queryOverride || values.queryInput @@ -1050,22 +611,21 @@ export const multitabEditorLogic = kea([ ...values.sourceQuery, source: newSource, }) - dataNodeLogic({ - key: values.dataLogicKey, - query: newSource, - }).mount() - + if (!cache.umountDataNode) { + cache.umountDataNode = dataNodeLogic({ + key: values.dataLogicKey, + query: newSource, + }).mount() + } dataNodeLogic({ key: values.dataLogicKey, query: newSource, }).actions.loadData(!switchTab ? 'force_async' : 'async') - - actions.updateState() }, saveAsView: async ({ fromDraft, materializeAfterSave = false }) => { LemonDialog.openForm({ title: 'Save as view', - initialValues: { viewName: values.activeModelUri?.name || '' }, + initialValues: { viewName: values.activeTab?.name || '' }, description: `View names can only contain letters, numbers, '_', or '$'. Spaces are not allowed.`, content: (isLoading) => isLoading ? ( @@ -1118,8 +678,6 @@ export const multitabEditorLogic = kea([ types, }) - actions.updateState() - // Saved queries are unique by team,name const savedQuery = dataWarehouseViewsLogic.values.dataWarehouseSavedQueries.find((q) => q.name === name) @@ -1165,10 +723,6 @@ export const multitabEditorLogic = kea([ lemonToast.info(`You're now viewing ${insight.name || insight.derived_name || name}`) - if (values.activeModelUri) { - actions._deleteTab(values.activeModelUri) - } - router.actions.push(urls.insightView(insight.short_id)) }, updateInsight: async () => { @@ -1176,7 +730,7 @@ export const multitabEditorLogic = kea([ return } - const insightName = values.activeModelUri?.name + const insightName = values.activeTab?.name const insightRequest: Partial = { name: insightName ?? values.editingInsight.name, @@ -1185,101 +739,45 @@ export const multitabEditorLogic = kea([ const savedInsight = await insightsApi.update(values.editingInsight.id, insightRequest) - if (values.activeModelUri) { + if (values.activeTab) { actions.updateTab({ - ...values.activeModelUri, + ...values.activeTab, insight: savedInsight, }) - actions.updateState(true) } lemonToast.info(`You're now viewing ${savedInsight.name || savedInsight.derived_name || name}`) - if (values.activeModelUri) { - actions._deleteTab(values.activeModelUri) - } - router.actions.push(urls.insightView(savedInsight.short_id)) }, loadDataWarehouseSavedQueriesSuccess: ({ dataWarehouseSavedQueries }) => { // keep tab views up to date - const newTabs = values.allTabs.map((tab) => ({ - ...tab, - view: dataWarehouseSavedQueries.find((v) => v.id === tab.view?.id), - })) - actions.setTabs(newTabs) - actions.updateState() + const tab = values.activeTab + const view = dataWarehouseSavedQueries.find((v) => v.id === tab.view?.id) + if (tab && view) { + actions.setTabs([{ ...tab, view }]) + actions.setQueryInput(view.query.query || '') + } }, deleteDataWarehouseSavedQuerySuccess: ({ payload: viewId }) => { - const tabToRemove = values.allTabs.find((tab) => tab.view?.id === viewId && !tab.draft) - if (tabToRemove) { - actions._deleteTab(tabToRemove) + const mustRemoveTab = values.allTabs.find((tab) => tab.view?.id === viewId && !tab.draft) + if (mustRemoveTab) { + actions.setTabs([]) + actions.createTab() } lemonToast.success('View deleted') - actions.updateState() }, createDataWarehouseSavedQuerySuccess: ({ dataWarehouseSavedQueries, payload: view }) => { const newView = view && dataWarehouseSavedQueries.find((v) => v.name === view.name) if (newView) { - const newTabs = values.allTabs.map((tab) => ({ - ...tab, - view: tab.uri.path === values.activeModelUri?.uri.path ? newView : tab.view, - })) - const newTab = newTabs.find((tab) => tab.uri.path === values.activeModelUri?.uri.path) - actions.setTabs(newTabs) - newTab && actions._selectTab(newTab) - actions.updateState() + const oldTab = values.activeTab + if (oldTab) { + actions.updateTab({ ...oldTab, view: newView }) + } } }, - updateDataWarehouseSavedQuerySuccess: ({ dataWarehouseSavedQueries }) => { - // // check if the active tab is a view and if so, update the view - const activeTab = dataWarehouseSavedQueries.find((tab) => tab.id === values.activeModelUri?.view?.id) - if (activeTab && values.activeModelUri) { - actions._selectTab({ - ...values.activeModelUri, - view: activeTab, - }) - } + updateDataWarehouseSavedQuerySuccess: () => { lemonToast.success('View updated') - actions.updateState() - }, - updateQueryTabState: async ({ skipBreakpoint }, breakpoint) => { - if (skipBreakpoint !== true) { - await breakpoint(1000) - } - - if (!values.queryTabState) { - return - } - try { - await api.queryTabState.update(values.queryTabState.id, { - state: { - editorModelsStateKey: await getStorageItem(editorModelsStateKey(props.key)), - activeModelStateKey: await getStorageItem(activeModelStateKey(props.key)), - sourceQuery: JSON.stringify(values.sourceQuery), - }, - }) - } catch (e) { - console.error(e) - } - }, - setResponse: ({ response, currentTab }) => { - if (!currentTab || !response) { - return - } - - const responseInBytes = sizeOfInBytes(response) - - const existingTab = values.allTabs.find((tab) => tab.uri.path === currentTab.uri.path) - - // Store in local storage if the response is less than 1 MB - if (responseInBytes <= 1024 * 1024 && existingTab) { - actions.updateTab({ - ...existingTab, - response, - }) - } - actions.updateState() }, updateView: async ({ view, draftId }) => { const latestView = await api.dataWarehouseSavedQueries.get(view.id) @@ -1323,7 +821,7 @@ export const multitabEditorLogic = kea([ actions.setTabs(newTabs) }, })), - subscriptions(({ props, actions, values }) => ({ + subscriptions(({ actions, values }) => ({ showLegacyFilters: (showLegacyFilters: boolean) => { if (showLegacyFilters) { actions.setSourceQuery({ @@ -1343,37 +841,6 @@ export const multitabEditorLogic = kea([ }) } }, - activeModelUri: (activeModelUri) => { - if (props.monaco) { - const _model = props.monaco.editor.getModel(activeModelUri.uri) - const val = _model?.getValue() - actions.setQueryInput(val ?? '') - if (activeModelUri.sourceQuery) { - actions.setSourceQuery({ - ...activeModelUri.sourceQuery, - source: { - ...activeModelUri.sourceQuery.source, - query: val ?? '', - }, - }) - } else { - actions.setSourceQuery({ - kind: NodeKind.DataVisualizationNode, - source: { - kind: NodeKind.HogQLQuery, - query: val ?? '', - }, - display: ChartDisplayType.ActionsLineGraph, - }) - } - } - }, - allTabs: (allTabs) => { - const activeTab = allTabs.find((tab: QueryTab) => tab.uri.path === values.activeModelUri?.uri.path) - if (activeTab && activeTab.uri.path != values.activeModelUri?.uri.path) { - actions._selectTab(activeTab) - } - }, editingView: (editingView) => { if (editingView) { actions.resetDataModelingJobs() @@ -1395,12 +862,7 @@ export const multitabEditorLogic = kea([ }, })), selectors({ - activeTab: [ - (s) => [s.activeModelUri, s.allTabs], - (activeModelUri, allTabs) => { - return allTabs.find((tab) => tab.uri.toString() === activeModelUri?.uri.toString()) - }, - ], + activeTab: [(s) => [s.allTabs], (allTabs: QueryTab[]) => allTabs?.[0] ?? null], suggestedSource: [ (s) => [s.suggestionPayload], (suggestionPayload) => { @@ -1451,12 +913,9 @@ export const multitabEditorLogic = kea([ }, ], editingView: [ - (s) => [s.activeModelUri, s.allTabs], - (activeModelUri, allTabs) => { - const currentTab = allTabs.find( - (tab: QueryTab) => tab.uri.toString() === activeModelUri?.uri.toString() - ) - return currentTab?.view + (s) => [s.activeTab], + (activeTab) => { + return activeTab?.view }, ], changesToSave: [ @@ -1490,22 +949,19 @@ export const multitabEditorLogic = kea([ }, ], updateInsightButtonEnabled: [ - (s) => [s.sourceQuery, s.activeModelUri], - (sourceQuery, activeModelUri) => { - if (!activeModelUri?.insight?.query || !activeModelUri.sourceQuery) { + (s) => [s.sourceQuery, s.activeTab], + (sourceQuery, activeTab) => { + if (!activeTab?.insight?.query || !activeTab.sourceQuery) { return false } - const updatedName = activeModelUri.name !== activeModelUri.insight.name + const updatedName = activeTab.name !== activeTab.insight.name const sourceQueryWithoutUndefinedAndNullKeys = removeUndefinedAndNull(sourceQuery) return ( updatedName || - !isEqual( - sourceQueryWithoutUndefinedAndNullKeys, - removeUndefinedAndNull(activeModelUri.insight.query) - ) + !isEqual(sourceQueryWithoutUndefinedAndNullKeys, removeUndefinedAndNull(activeTab.insight.query)) ) }, ], @@ -1515,51 +971,72 @@ export const multitabEditorLogic = kea([ return queryInput.indexOf('{filters}') !== -1 || queryInput.indexOf('{filters.') !== -1 }, ], - dataLogicKey: [ - (s) => [s.activeModelUri, s.editingInsight], - (activeModelUri, editingInsight) => { - if (editingInsight) { - return `InsightViz.${editingInsight.short_id}` + dataLogicKey: [(_, p) => [p.tabId], (tabId) => `data-warehouse-editor-data-node-${tabId}`], + isDraft: [(s) => [s.activeTab], (activeTab) => (activeTab ? !!activeTab.draft?.id : false)], + currentDraft: [(s) => [s.activeTab], (activeTab) => (activeTab ? activeTab.draft : null)], + breadcrumbs: [ + (s) => [s.activeTab], + (activeTab): Breadcrumb[] => { + const { draft, insight, view } = activeTab || {} + const first = { + key: Scene.SQLEditor, + name: 'SQL query', + to: urls.sqlEditor(), } - - return ( - activeModelUri?.uri.path ?? - insightVizDataNodeKey({ - dashboardItemId: DATAWAREHOUSE_EDITOR_ITEM_ID, - cachedInsight: null, - doNotLoad: true, - }) - ) - }, - ], - localStorageResponse: [ - (s) => [s.activeModelUri], - (activeModelUri) => { - return activeModelUri?.response - }, - ], - isDraft: [ - (s) => [s.activeModelUri, s.allTabs], - (activeModelUri, allTabs) => { - const currentTab = allTabs.find((tab) => tab.uri.toString() === activeModelUri?.uri.toString()) - return currentTab ? !!currentTab.draft?.id : false - }, - ], - currentDraft: [ - (s) => [s.activeModelUri, s.allTabs], - (activeModelUri, allTabs) => { - const currentTab = allTabs.find((tab) => tab.uri.toString() === activeModelUri?.uri.toString()) - return currentTab ? currentTab.draft : null + if (view) { + return [ + first, + { + key: view.id, + name: view.name, + path: urls.sqlEditor(undefined, view.id), + }, + ] + } else if (insight) { + return [ + first, + { + key: insight.id, + name: insight.name || insight.derived_name || 'Untitled', + path: urls.sqlEditor(undefined, undefined, insight.short_id), + }, + ] + } else if (draft) { + return [ + first, + { + key: draft.id, + name: draft.name || 'Untitled', + path: urls.sqlEditor(undefined, undefined, undefined, draft.id), + }, + ] + } + return [first] }, ], }), - urlToAction(({ actions, values, props }) => ({ - [urls.sqlEditor()]: async (_, searchParams) => { + tabAwareActionToUrl(({ values }) => ({ + setQueryInput: () => { + if (values.queryInput) { + return [urls.sqlEditor(), undefined, getTabHash(values), { replace: true }] + } + }, + createTab: () => { + if (values.queryInput) { + return [urls.sqlEditor(), undefined, getTabHash(values), { replace: true }] + } + }, + })), + tabAwareUrlToAction(({ actions, values, props }) => ({ + [urls.sqlEditor()]: async (_, searchParams, hashParams) => { if ( !searchParams.open_query && !searchParams.open_view && !searchParams.open_insight && - !searchParams.open_draft + !searchParams.open_draft && + !hashParams.q && + !hashParams.view && + !hashParams.insight ) { return } @@ -1567,8 +1044,8 @@ export const multitabEditorLogic = kea([ let tabAdded = false const createQueryTab = async (): Promise => { - if (searchParams.open_draft) { - const draftId = searchParams.open_draft + if (searchParams.open_draft || (hashParams.draft && !values.queryInput)) { + const draftId = searchParams.open_draft || hashParams.draft const draft = values.drafts.find((draft) => { return (draft.id = draftId) }) @@ -1582,9 +1059,7 @@ export const multitabEditorLogic = kea([ return tab.draft?.id === draft.id }) - if (existingTab) { - actions.selectTab(existingTab) - } else { + if (!existingTab) { const associatedView = draft.saved_query_id ? values.dataWarehouseSavedQueryMapById[draft.saved_query_id] : undefined @@ -1597,9 +1072,9 @@ export const multitabEditorLogic = kea([ } } return - } else if (searchParams.open_view) { + } else if (searchParams.open_view || (hashParams.view && !values.queryInput)) { // Open view - const viewId = searchParams.open_view + const viewId = searchParams.open_view || hashParams.view if (values.dataWarehouseSavedQueries.length === 0) { await dataWarehouseViewsLogic.asyncActions.loadDataWarehouseSavedQueries() @@ -1615,18 +1090,18 @@ export const multitabEditorLogic = kea([ actions.editView(queryToOpen, view) tabAdded = true - router.actions.replace(router.values.location.pathname) - } else if (searchParams.open_insight) { - if (searchParams.open_insight === 'new') { + router.actions.replace(urls.sqlEditor(), undefined, getTabHash(values)) + } else if (searchParams.open_insight || (hashParams.insight && !values.queryInput)) { + const shortId = searchParams.open_insight || hashParams.insight + if (shortId === 'new') { // Add new blank tab actions.createTab() tabAdded = true - router.actions.replace(router.values.location.pathname) + router.actions.replace(urls.sqlEditor(), undefined, getTabHash(values)) return } // Open Insight - const shortId = searchParams.open_insight const insight = await insightsApi.getByShortId(shortId, undefined, 'async') if (!insight) { lemonToast.error('Insight not found') @@ -1666,12 +1141,15 @@ export const multitabEditorLogic = kea([ } tabAdded = true - router.actions.replace(router.values.location.pathname) + router.actions.replace(urls.sqlEditor(), undefined, getTabHash(values)) } else if (searchParams.open_query) { // Open query string actions.createTab(searchParams.open_query) tabAdded = true - router.actions.replace(router.values.location.pathname) + } else if (hashParams.q && !values.queryInput) { + // only when opening the tab + actions.createTab(hashParams.q) + tabAdded = true } } @@ -1697,7 +1175,7 @@ export const multitabEditorLogic = kea([ }) }, })), - afterMount(({ actions }) => { - actions.loadQueryTabState() + beforeUnmount(({ cache }) => { + cache.umountDataNode?.() }), ]) diff --git a/frontend/src/scenes/data-warehouse/editor/sidebar/QueryDatabase.tsx b/frontend/src/scenes/data-warehouse/editor/sidebar/QueryDatabase.tsx index d39cea4470..db7a0dc3b8 100644 --- a/frontend/src/scenes/data-warehouse/editor/sidebar/QueryDatabase.tsx +++ b/frontend/src/scenes/data-warehouse/editor/sidebar/QueryDatabase.tsx @@ -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 => {
{ 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 ( + + { + e.stopPropagation() + if (item.record) { + openUnsavedQuery(item.record) + } + }} + > + Open + + { + e.stopPropagation() + if (item.record) { + deleteUnsavedQuery(item.record) + } + }} + > + Discard + + + ) + } + if (item.record?.type === 'sources') { // used to override default icon behavior return null diff --git a/frontend/src/scenes/data-warehouse/editor/sidebar/QueryInfo.tsx b/frontend/src/scenes/data-warehouse/editor/sidebar/QueryInfo.tsx index fde6284689..3a5b90b0e6 100644 --- a/frontend/src/scenes/data-warehouse/editor/sidebar/QueryInfo.tsx +++ b/frontend/src/scenes/data-warehouse/editor/sidebar/QueryInfo.tsx @@ -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} /> ) : ( - + )} )} diff --git a/frontend/src/scenes/data-warehouse/editor/sidebar/graph/UpstreamGraph.tsx b/frontend/src/scenes/data-warehouse/editor/sidebar/graph/UpstreamGraph.tsx index 69eb6e7b0c..4570c289ee 100644 --- a/frontend/src/scenes/data-warehouse/editor/sidebar/graph/UpstreamGraph.tsx +++ b/frontend/src/scenes/data-warehouse/editor/sidebar/graph/UpstreamGraph.tsx @@ -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) => , +const getNodeTypes = (edges: { source: string; target: string }[], tabId: string): NodeTypes => ({ + lineageNode: (props) => , }) 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 (
- +
) diff --git a/frontend/src/scenes/data-warehouse/editor/sidebar/infoTabLogic.ts b/frontend/src/scenes/data-warehouse/editor/sidebar/infoTabLogic.ts index f04ab9779f..1f0cf307ba 100644 --- a/frontend/src/scenes/data-warehouse/editor/sidebar/infoTabLogic.ts +++ b/frontend/src/scenes/data-warehouse/editor/sidebar/infoTabLogic.ts @@ -15,16 +15,16 @@ export interface InfoTableRow { } export interface InfoTabLogicProps { - codeEditorKey: string + tabId: string } export const infoTabLogic = kea([ 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'], diff --git a/frontend/src/scenes/data-warehouse/editor/sidebar/queryDatabaseLogic.tsx b/frontend/src/scenes/data-warehouse/editor/sidebar/queryDatabaseLogic.tsx index 6ab5ccceb0..ef162d3299 100644 --- a/frontend/src/scenes/data-warehouse/editor/sidebar/queryDatabaseLogic.tsx +++ b/frontend/src/scenes/data-warehouse/editor/sidebar/queryDatabaseLogic.tsx @@ -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([ selectSourceTable: (tableName: string) => ({ tableName }), setSyncMoreNoticeDismissed: (dismissed: boolean) => ({ dismissed }), setEditingDraft: (draftId: string) => ({ draftId }), + openUnsavedQuery: (record: Record) => ({ record }), + deleteUnsavedQuery: (record: Record) => ({ record }), }), connect(() => ({ values: [ @@ -343,6 +350,8 @@ export const queryDatabaseLogic = kea([ ['drafts', 'draftsResponseLoading', 'hasMoreDrafts'], featureFlagLogic, ['featureFlags'], + userLogic, + ['user'], ], actions: [ viewLinkLogic, @@ -408,6 +417,50 @@ export const queryDatabaseLogic = kea([ }, ], }), + 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([ s.draftsResponseLoading, s.hasMoreDrafts, s.featureFlags, + s.queryTabState, ], ( posthogTables: DatabaseSchemaTable[], @@ -614,7 +668,8 @@ export const queryDatabaseLogic = kea([ 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([ 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: , + 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([ ...(featureFlags[FEATURE_FLAGS.EDITOR_DRAFTS] ? [createTopLevelFolderNode('drafts', draftsChildren, false)] : []), + ...(unsavedChildren.length > 0 + ? [ + { + id: 'unsaved-folder', + name: 'Unsaved queries', + type: 'node', + icon: , + record: { + type: 'unsaved-folder', + }, + children: unsavedChildren, + } as TreeDataItem, + ] + : []), createTopLevelFolderNode('views', viewsChildren), createTopLevelFolderNode('managed-views', managedViewsChildren), ] @@ -850,6 +938,15 @@ export const queryDatabaseLogic = kea([ 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([ if (values.featureFlags[FEATURE_FLAGS.EDITOR_DRAFTS]) { actions.loadDrafts() } + actions.loadQueryTabState() }, })), ]) diff --git a/frontend/src/scenes/new-tab/newTabSceneLogic.tsx b/frontend/src/scenes/new-tab/newTabSceneLogic.tsx index 192a1cf8b3..6947df67b5 100644 --- a/frontend/src/scenes/new-tab/newTabSceneLogic.tsx +++ b/frontend/src/scenes/new-tab/newTabSceneLogic.tsx @@ -133,7 +133,7 @@ export const newTabSceneLogic = kea([ const queryTree: ItemsGridItem[] = [ { category: 'Create new insight', - types: [{ name: 'SQL editor', icon: , href: '/sql' }, ...newInsightItems], + types: [{ name: 'SQL query', icon: , href: '/sql' }, ...newInsightItems], }, { category: 'Create new ...', diff --git a/package.json b/package.json index 55ee893f81..a3a236f608 100644 --- a/package.json +++ b/package.json @@ -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}": [ diff --git a/playwright/e2e/sql-editor.spec.ts b/playwright/e2e/sql-editor.spec.ts index 5d3380c6d6..01f51c1b28 100644 --- a/playwright/e2e/sql-editor.spec.ts +++ b/playwright/e2e/sql-editor.spec.ts @@ -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 }) => {