diff --git a/frontend/__snapshots__/posthog-3000-navigation--navigation-3000--dark.png b/frontend/__snapshots__/posthog-3000-navigation--navigation-3000--dark.png index e7d6b2a7d83..9e94e50e113 100644 Binary files a/frontend/__snapshots__/posthog-3000-navigation--navigation-3000--dark.png and b/frontend/__snapshots__/posthog-3000-navigation--navigation-3000--dark.png differ diff --git a/frontend/__snapshots__/posthog-3000-navigation--navigation-3000--light.png b/frontend/__snapshots__/posthog-3000-navigation--navigation-3000--light.png index d360db633aa..afcf5cd9c03 100644 Binary files a/frontend/__snapshots__/posthog-3000-navigation--navigation-3000--light.png and b/frontend/__snapshots__/posthog-3000-navigation--navigation-3000--light.png differ diff --git a/frontend/__snapshots__/posthog-3000-sidebar--dashboards--dark.png b/frontend/__snapshots__/posthog-3000-sidebar--dashboards--dark.png index 7f417ca6f1d..d66b1f11c1a 100644 Binary files a/frontend/__snapshots__/posthog-3000-sidebar--dashboards--dark.png and b/frontend/__snapshots__/posthog-3000-sidebar--dashboards--dark.png differ diff --git a/frontend/__snapshots__/posthog-3000-sidebar--dashboards--light.png b/frontend/__snapshots__/posthog-3000-sidebar--dashboards--light.png index 04c5d123699..eef82575bea 100644 Binary files a/frontend/__snapshots__/posthog-3000-sidebar--dashboards--light.png and b/frontend/__snapshots__/posthog-3000-sidebar--dashboards--light.png differ diff --git a/frontend/__snapshots__/posthog-3000-sidebar--feature-flags--dark.png b/frontend/__snapshots__/posthog-3000-sidebar--feature-flags--dark.png index ce20b2d82ad..687b3e52583 100644 Binary files a/frontend/__snapshots__/posthog-3000-sidebar--feature-flags--dark.png and b/frontend/__snapshots__/posthog-3000-sidebar--feature-flags--dark.png differ diff --git a/frontend/__snapshots__/posthog-3000-sidebar--feature-flags--light.png b/frontend/__snapshots__/posthog-3000-sidebar--feature-flags--light.png index bf60bd921e3..01823e88fef 100644 Binary files a/frontend/__snapshots__/posthog-3000-sidebar--feature-flags--light.png and b/frontend/__snapshots__/posthog-3000-sidebar--feature-flags--light.png differ diff --git a/frontend/__snapshots__/scenes-app-insights--funnel-top-to-bottom-breakdown-edit--dark.png b/frontend/__snapshots__/scenes-app-insights--funnel-top-to-bottom-breakdown-edit--dark.png index 0725eb97f8d..64260538a2c 100644 Binary files a/frontend/__snapshots__/scenes-app-insights--funnel-top-to-bottom-breakdown-edit--dark.png and b/frontend/__snapshots__/scenes-app-insights--funnel-top-to-bottom-breakdown-edit--dark.png differ diff --git a/frontend/src/layout/navigation-3000/Navigation.scss b/frontend/src/layout/navigation-3000/Navigation.scss index 07bdfe66828..df5f78ab272 100644 --- a/frontend/src/layout/navigation-3000/Navigation.scss +++ b/frontend/src/layout/navigation-3000/Navigation.scss @@ -175,7 +175,7 @@ .Sidebar3000 { --sidebar-slider-padding: 0.125rem; --sidebar-horizontal-padding: 0.5rem; - --sidebar-row-height: 2rem; + --sidebar-row-height: 2.5rem; --sidebar-background: var(--bg-3000); position: relative; @@ -451,7 +451,8 @@ } // Accommodate menu button by moving stuff out of the way - &.SidebarListItem--has-menu:not(.SidebarListItem--extended) .SidebarListItem__link { + &.SidebarListItem--has-menu:not(.SidebarListItem--extended) .SidebarListItem__link, + &.SidebarListItem--has-menu:not(.SidebarListItem--extended) .SidebarListItem__button { padding-right: calc(var(--sidebar-horizontal-padding) + 1.25rem); } @@ -523,6 +524,7 @@ } } +.SidebarListItem__button, .SidebarListItem__link, .SidebarListItem__rename { --sidebar-list-item-inset: calc( @@ -555,6 +557,17 @@ } } +.SidebarListItem__button { + row-gap: 1px; + padding: 0 var(--sidebar-horizontal-padding) 0 var(--sidebar-list-item-inset); + color: inherit !important; // Disable link color + cursor: pointer; + + &:hover { + background: var(--border-3000); + } +} + .SidebarListItem__rename { // Pseudo-elements don't work on inputs, so we use a wrapper div background: var(--bg-light); diff --git a/frontend/src/layout/navigation-3000/components/Navbar.tsx b/frontend/src/layout/navigation-3000/components/Navbar.tsx index 62308871fc1..c0c64122782 100644 --- a/frontend/src/layout/navigation-3000/components/Navbar.tsx +++ b/frontend/src/layout/navigation-3000/components/Navbar.tsx @@ -27,7 +27,7 @@ export function Navbar(): JSX.Element { const { isAccountPopoverOpen, systemStatusHealthy } = useValues(navigationLogic) const { closeAccountPopover, toggleAccountPopover } = useActions(navigationLogic) const { isNavShown, isSidebarShown, activeNavbarItemId, navbarItems, mobileLayout } = useValues(navigation3000Logic) - const { showSidebar, hideSidebar, toggleNavCollapsed, hideNavOnMobile } = useActions(navigation3000Logic) + const { toggleNavCollapsed, hideNavOnMobile, showSidebar, hideSidebar } = useActions(navigation3000Logic) const { featureFlags } = useValues(featureFlagLogic) const { toggleSearchBar } = useActions(commandBarLogic) diff --git a/frontend/src/layout/navigation-3000/components/Sidebar.tsx b/frontend/src/layout/navigation-3000/components/Sidebar.tsx index 52610910586..96497e047ff 100644 --- a/frontend/src/layout/navigation-3000/components/Sidebar.tsx +++ b/frontend/src/layout/navigation-3000/components/Sidebar.tsx @@ -19,8 +19,16 @@ const SEARCH_DEBOUNCE_MS = 300 interface SidebarProps { navbarItem: SidebarNavbarItem // Sidebar can only be rendered if there's an active sidebar navbar item + sidebarOverlay?: React.ReactNode + sidebarOverlayProps?: SidebarOverlayProps } -export function Sidebar({ navbarItem }: SidebarProps): JSX.Element { + +interface SidebarOverlayProps { + className?: string + isOpen?: boolean +} + +export function Sidebar({ navbarItem, sidebarOverlay, sidebarOverlayProps }: SidebarProps): JSX.Element { const inputElementRef = useRef(null) const { @@ -81,6 +89,11 @@ export function Sidebar({ navbarItem }: SidebarProps): JSX.Element { } }} /> + {sidebarOverlay && ( + + {sidebarOverlay} + + )} ) } @@ -199,3 +212,24 @@ function SidebarKeyboardShortcut(): JSX.Element { ) } + +function SidebarOverlay({ + className, + isOpen = false, + children, + width, +}: SidebarOverlayProps & { children: React.ReactNode; width: number }): JSX.Element | null { + if (!isOpen) { + return null + } + + return ( +
+ {children} +
+ ) +} diff --git a/frontend/src/layout/navigation-3000/components/SidebarList.tsx b/frontend/src/layout/navigation-3000/components/SidebarList.tsx index d42b257b15d..2b63b9a61e9 100644 --- a/frontend/src/layout/navigation-3000/components/SidebarList.tsx +++ b/frontend/src/layout/navigation-3000/components/SidebarList.tsx @@ -13,7 +13,14 @@ import { InfiniteLoader } from 'react-virtualized/dist/es/InfiniteLoader' import { List, ListProps } from 'react-virtualized/dist/es/List' import { ITEM_KEY_PART_SEPARATOR, navigation3000Logic } from '../navigationLogic' -import { BasicListItem, ExtendedListItem, ExtraListItemContext, SidebarCategory, TentativeListItem } from '../types' +import { + BasicListItem, + ButtonListItem, + ExtendedListItem, + ExtraListItemContext, + SidebarCategory, + TentativeListItem, +} from '../types' import { KeyboardShortcut } from './KeyboardShortcut' export function SidebarList({ category }: { category: SidebarCategory }): JSX.Element { @@ -122,7 +129,7 @@ export function SidebarList({ category }: { category: SidebarCategory }): JSX.El } interface SidebarListItemProps { - item: BasicListItem | ExtendedListItem | TentativeListItem + item: BasicListItem | ExtendedListItem | TentativeListItem | ButtonListItem validateName?: SidebarCategory['validateName'] active?: boolean style: React.CSSProperties @@ -132,6 +139,10 @@ function isItemTentative(item: SidebarListItemProps['item']): item is TentativeL return 'onSave' in item } +function isItemClickable(item: SidebarListItemProps['item']): item is ButtonListItem { + return 'onClick' in item +} + function SidebarListItem({ item, validateName, active, style }: SidebarListItemProps): JSX.Element { const [isMenuOpen, setIsMenuOpen] = useState(false) const [newName, setNewName] = useState(null) @@ -218,7 +229,13 @@ function SidebarListItem({ item, validateName, active, style }: SidebarListItemP }) // Intentionally run on every render so that ref value changes are picked up let content: JSX.Element - if (!save || (!isItemTentative(item) && newName === null)) { + if (isItemClickable(item)) { + content = ( +
  • +
    {item.name}
    +
  • + ) + } else if (!save || (!isItemTentative(item) && newName === null)) { if (isItemTentative(item)) { throw new Error('Tentative items should not be rendered in read mode') } diff --git a/frontend/src/layout/navigation-3000/navigationLogic.tsx b/frontend/src/layout/navigation-3000/navigationLogic.tsx index ca43417d405..4a81a00349c 100644 --- a/frontend/src/layout/navigation-3000/navigationLogic.tsx +++ b/frontend/src/layout/navigation-3000/navigationLogic.tsx @@ -31,6 +31,7 @@ import { LemonMenuOverlay } from 'lib/lemon-ui/LemonMenu/LemonMenu' import { featureFlagLogic } from 'lib/logic/featureFlagLogic' import { isNotNil } from 'lib/utils' import React from 'react' +import { editorSidebarLogic } from 'scenes/data-warehouse/editor/editorSidebarLogic' import { sceneLogic } from 'scenes/sceneLogic' import { Scene } from 'scenes/sceneTypes' import { teamLogic } from 'scenes/teamLogic' @@ -103,9 +104,6 @@ export const navigation3000Logic = kea([ reducers({ isSidebarShown: [ true, - { - persist: true, - }, { hideSidebar: () => false, showSidebar: () => true, @@ -514,9 +512,10 @@ export const navigation3000Logic = kea([ featureFlags[FEATURE_FLAGS.SQL_EDITOR] ? { identifier: Scene.SQLEditor, - label: 'SQL editor', + label: 'Data warehouse', icon: , - to: isUsingSidebar ? undefined : urls.sqlEditor(), + to: urls.sqlEditor(), + logic: editorSidebarLogic, } : null, featureFlags[FEATURE_FLAGS.DATA_MODELING] && hasOnboardedAnyProduct @@ -598,6 +597,9 @@ export const navigation3000Logic = kea([ activeNavbarItemId: [ (s) => [s.activeNavbarItemIdRaw, featureFlagLogic.selectors.featureFlags], (activeNavbarItemIdRaw, featureFlags): string | null => { + if (featureFlags[FEATURE_FLAGS.SQL_EDITOR] && activeNavbarItemIdRaw === Scene.SQLEditor) { + return Scene.SQLEditor + } if (!featureFlags[FEATURE_FLAGS.POSTHOG_3000_NAV]) { return null } diff --git a/frontend/src/layout/navigation-3000/types.ts b/frontend/src/layout/navigation-3000/types.ts index 2ef13b34c25..3f79f6dbda4 100644 --- a/frontend/src/layout/navigation-3000/types.ts +++ b/frontend/src/layout/navigation-3000/types.ts @@ -104,6 +104,7 @@ export interface BasicListItem { * URL within the app. In specific cases this can be null - such items are italicized. */ url: string | null + onClick?: () => void /** An optional marker to highlight item state. */ marker?: { /** A marker of type `fold` is a small triangle in the top left, `ribbon` is a narrow ribbon to the left. */ @@ -146,3 +147,8 @@ export interface TentativeListItem { adding: boolean ref?: BasicListItem['ref'] } + +export interface ButtonListItem extends BasicListItem { + key: '__button__' + onClick: () => void +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index c4e17361415..774bf3522ad 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -2206,7 +2206,7 @@ const api = { }, async update( viewId: DataWarehouseSavedQuery['id'], - data: Pick + data: Partial ): Promise { return await new ApiRequest().dataWarehouseSavedQuery(viewId).update({ data }) }, diff --git a/frontend/src/lib/lemon-ui/LemonDialog/LemonDialog.tsx b/frontend/src/lib/lemon-ui/LemonDialog/LemonDialog.tsx index ca3b0a1cf25..91c8af8355c 100644 --- a/frontend/src/lib/lemon-ui/LemonDialog/LemonDialog.tsx +++ b/frontend/src/lib/lemon-ui/LemonDialog/LemonDialog.tsx @@ -12,6 +12,7 @@ export type LemonFormDialogProps = LemonDialogFormPropsType & Omit & { initialValues: Record onSubmit: (values: Record) => void | Promise + shouldAwaitSubmit?: boolean } export type LemonDialogProps = Pick< @@ -26,6 +27,7 @@ export type LemonDialogProps = Pick< onClose?: () => void onAfterClose?: () => void closeOnNavigate?: boolean + shouldAwaitSubmit?: boolean } export function LemonDialog({ @@ -37,12 +39,14 @@ export function LemonDialog({ content, initialFormValues, closeOnNavigate = true, + shouldAwaitSubmit = false, footer, ...props }: LemonDialogProps): JSX.Element { const [isOpen, setIsOpen] = useState(true) const { currentLocation } = useValues(router) const lastLocation = useRef(currentLocation.pathname) + const [isLoading, setIsLoading] = useState(false) primaryButton = primaryButton || @@ -63,8 +67,20 @@ export function LemonDialog({ { - button.onClick?.(e) + loading={button === primaryButton && shouldAwaitSubmit ? isLoading : undefined} + // eslint-disable-next-line @typescript-eslint/no-misused-promises + onClick={async (e) => { + if (button === primaryButton && shouldAwaitSubmit) { + setIsLoading(true) + try { + // eslint-disable-next-line @typescript-eslint/await-thenable + await button.onClick?.(e) + } finally { + setIsLoading(false) + } + } else { + button.onClick?.(e) + } setIsOpen(false) }} /> @@ -117,7 +133,8 @@ export const LemonFormDialog = ({ type: 'primary', children: 'Submit', htmlType: 'submit', - onClick: () => void onSubmit(form), + // eslint-disable-next-line @typescript-eslint/no-misused-promises + onClick: props.shouldAwaitSubmit ? async () => await onSubmit(form) : () => void onSubmit(form), disabledReason: !isFormValid ? firstError : undefined, } diff --git a/frontend/src/scenes/data-warehouse/editor/EditorScene.tsx b/frontend/src/scenes/data-warehouse/editor/EditorScene.tsx index 1b36477047c..3576303ebdd 100644 --- a/frontend/src/scenes/data-warehouse/editor/EditorScene.tsx +++ b/frontend/src/scenes/data-warehouse/editor/EditorScene.tsx @@ -1,14 +1,23 @@ -import { BindLogic } from 'kea' +import { IconArrowLeft } from '@posthog/icons' +import { BindLogic, useActions, useValues } from 'kea' +import { CopyToClipboardInline } from 'lib/components/CopyToClipboard' +import { DatabaseTableTree } from 'lib/components/DatabaseTableTree/DatabaseTableTree' +import { LemonButton } from 'lib/lemon-ui/LemonButton' import { useRef } from 'react' +import { Sidebar } from '~/layout/navigation-3000/components/Sidebar' +import { navigation3000Logic } from '~/layout/navigation-3000/navigationLogic' + +import { editorSceneLogic } from './editorSceneLogic' import { editorSizingLogic } from './editorSizingLogic' import { QueryWindow } from './QueryWindow' -import { SourceNavigator } from './SourceNavigator' export function EditorScene(): JSX.Element { const ref = useRef(null) const navigatorRef = useRef(null) const queryPaneRef = useRef(null) + const { activeNavbarItem } = useValues(navigation3000Logic) + const { sidebarOverlayOpen } = useValues(editorSceneLogic) const editorSizingLogicProps = { editorSceneRef: ref, @@ -28,9 +37,41 @@ export function EditorScene(): JSX.Element { return (
    - + {activeNavbarItem && ( + } + sidebarOverlayProps={{ isOpen: sidebarOverlayOpen }} + /> + )}
    ) } + +const EditorSidebarOverlay = (): JSX.Element => { + const { setSidebarOverlayOpen } = useActions(editorSceneLogic) + const { sidebarOverlayTreeItems, selectedSchema } = useValues(editorSceneLogic) + + return ( +
    +
    + } onClick={() => setSidebarOverlayOpen(false)} /> + {selectedSchema?.name && ( + + {selectedSchema?.name} + + )} +
    + +
    + ) +} diff --git a/frontend/src/scenes/data-warehouse/editor/QueryPane.tsx b/frontend/src/scenes/data-warehouse/editor/QueryPane.tsx index 8d28cf57614..10e36c436e7 100644 --- a/frontend/src/scenes/data-warehouse/editor/QueryPane.tsx +++ b/frontend/src/scenes/data-warehouse/editor/QueryPane.tsx @@ -1,6 +1,5 @@ import { useValues } from 'kea' import { Resizer } from 'lib/components/Resizer/Resizer' -import { LemonBanner } from 'lib/lemon-ui/LemonBanner' import { CodeEditor, CodeEditorProps } from 'lib/monaco/CodeEditor' import { AutoSizer } from 'react-virtualized/dist/es/AutoSizer' @@ -16,43 +15,44 @@ export function QueryPane(props: QueryPaneProps): JSX.Element { const { queryPaneHeight, queryPaneResizerProps } = useValues(editorSizingLogic) return ( -
    -
    - {props.promptError ? {props.promptError} : null} - - {({ height, width }) => ( - - )} - + <> +
    +
    + + {({ height, width }) => ( + + )} + +
    +
    - -
    + ) } diff --git a/frontend/src/scenes/data-warehouse/editor/QueryTabs.tsx b/frontend/src/scenes/data-warehouse/editor/QueryTabs.tsx index b49acba9584..35a41c0f402 100644 --- a/frontend/src/scenes/data-warehouse/editor/QueryTabs.tsx +++ b/frontend/src/scenes/data-warehouse/editor/QueryTabs.tsx @@ -1,41 +1,42 @@ import { IconPlus, IconX } from '@posthog/icons' import { LemonButton } from '@posthog/lemon-ui' import clsx from 'clsx' -import { Uri } from 'monaco-editor' + +import { QueryTab } from './multitabEditorLogic' interface QueryTabsProps { - models: Uri[] - onClick: (model: Uri) => void - onClear: (model: Uri) => void + models: QueryTab[] + onClick: (model: QueryTab) => void + onClear: (model: QueryTab) => void onAdd: () => void - activeModelUri: Uri | null + activeModelUri: QueryTab | null } export function QueryTabs({ models, onClear, onClick, onAdd, activeModelUri }: QueryTabsProps): JSX.Element { return ( -
    - {models.map((model: Uri) => ( - + {models.map((model: QueryTab) => ( + 1 ? onClear : undefined} onClick={onClick} - active={activeModelUri?.path === model.path} + active={activeModelUri?.uri.path === model.uri.path} /> ))} - } /> + onAdd()} icon={} />
    ) } interface QueryTabProps { - model: Uri - onClick: (model: Uri) => void - onClear?: (model: Uri) => void + model: QueryTab + onClick: (model: QueryTab) => void + onClear?: (model: QueryTab) => void active: boolean } -function QueryTab({ model, active, onClear, onClick }: QueryTabProps): JSX.Element { +function QueryTabComponent({ model, active, onClear, onClick }: QueryTabProps): JSX.Element { return (
    diff --git a/frontend/src/scenes/data-warehouse/editor/SourceNavigator.tsx b/frontend/src/scenes/data-warehouse/editor/SourceNavigator.tsx deleted file mode 100644 index ca9f4991245..00000000000 --- a/frontend/src/scenes/data-warehouse/editor/SourceNavigator.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { useValues } from 'kea' -import { Resizer } from 'lib/components/Resizer/Resizer' - -import { DatabaseTableTreeWithItems } from '../external/DataWarehouseTables' -import { editorSizingLogic } from './editorSizingLogic' -import { SchemaSearch } from './SchemaSearch' - -export function SourceNavigator(): JSX.Element { - const { sourceNavigatorWidth, sourceNavigatorResizerProps } = useValues(editorSizingLogic) - - return ( -
    - - - -
    - ) -} diff --git a/frontend/src/scenes/data-warehouse/editor/editorSceneLogic.ts b/frontend/src/scenes/data-warehouse/editor/editorSceneLogic.ts new file mode 100644 index 00000000000..175183d6020 --- /dev/null +++ b/frontend/src/scenes/data-warehouse/editor/editorSceneLogic.ts @@ -0,0 +1,56 @@ +import { actions, kea, path, reducers, selectors } from 'kea' +import { TreeItem } from 'lib/components/DatabaseTableTree/DatabaseTableTree' + +import { DatabaseSchemaDataWarehouseTable, DatabaseSchemaTable } from '~/queries/schema' +import { DataWarehouseSavedQuery } from '~/types' + +import type { editorSceneLogicType } from './editorSceneLogicType' + +export const editorSceneLogic = kea([ + path(['scenes', 'data-warehouse', 'editor', 'editorSceneLogic']), + actions({ + setSidebarOverlayOpen: (isOpen: boolean) => ({ isOpen }), + selectSchema: (schema: DatabaseSchemaDataWarehouseTable | DatabaseSchemaTable | DataWarehouseSavedQuery) => ({ + schema, + }), + }), + reducers({ + sidebarOverlayOpen: [ + false, + { + setSidebarOverlayOpen: (_, { isOpen }) => isOpen, + selectSchema: (_, { schema }) => schema !== null, + }, + ], + selectedSchema: [ + null as DatabaseSchemaDataWarehouseTable | DatabaseSchemaTable | DataWarehouseSavedQuery | null, + { + selectSchema: (_, { schema }) => schema, + }, + ], + }), + selectors({ + sidebarOverlayTreeItems: [ + (s) => [s.selectedSchema], + (selectedSchema): TreeItem[] => { + if (selectedSchema === null) { + return [] + } + if ('fields' in selectedSchema) { + return Object.values(selectedSchema.fields).map((field) => ({ + name: field.name, + type: field.type, + })) + } + + if ('columns' in selectedSchema) { + return Object.values(selectedSchema.columns).map((column) => ({ + name: column.name, + type: column.type, + })) + } + return [] + }, + ], + }), +]) diff --git a/frontend/src/scenes/data-warehouse/editor/editorSidebarLogic.ts b/frontend/src/scenes/data-warehouse/editor/editorSidebarLogic.ts new file mode 100644 index 00000000000..8239bd16655 --- /dev/null +++ b/frontend/src/scenes/data-warehouse/editor/editorSidebarLogic.ts @@ -0,0 +1,203 @@ +import Fuse from 'fuse.js' +import { connect, kea, path, selectors } from 'kea' +import { router } from 'kea-router' +import { subscriptions } from 'kea-subscriptions' +import { databaseTableListLogic } from 'scenes/data-management/database/databaseTableListLogic' +import { sceneLogic } from 'scenes/sceneLogic' +import { Scene } from 'scenes/sceneTypes' +import { urls } from 'scenes/urls' + +import { navigation3000Logic } from '~/layout/navigation-3000/navigationLogic' +import { FuseSearchMatch } from '~/layout/navigation-3000/sidebars/utils' +import { SidebarCategory } from '~/layout/navigation-3000/types' +import { DatabaseSchemaDataWarehouseTable, DatabaseSchemaTable } from '~/queries/schema' +import { DataWarehouseSavedQuery, PipelineTab } from '~/types' + +import { dataWarehouseViewsLogic } from '../saved_queries/dataWarehouseViewsLogic' +import { editorSceneLogic } from './editorSceneLogic' +import type { editorSidebarLogicType } from './editorSidebarLogicType' +import { multitabEditorLogic } from './multitabEditorLogic' + +const dataWarehouseTablesfuse = new Fuse([], { + keys: [{ name: 'name', weight: 2 }], + threshold: 0.3, + ignoreLocation: true, + includeMatches: true, +}) + +const posthogTablesfuse = new Fuse([], { + keys: [{ name: 'name', weight: 2 }], + threshold: 0.3, + ignoreLocation: true, + includeMatches: true, +}) + +const savedQueriesfuse = new Fuse([], { + keys: [{ name: 'name', weight: 2 }], + threshold: 0.3, + ignoreLocation: true, + includeMatches: true, +}) + +export const editorSidebarLogic = kea([ + path(['data-warehouse', 'editor', 'editorSidebarLogic']), + connect({ + values: [ + sceneLogic, + ['activeScene', 'sceneParams'], + dataWarehouseViewsLogic, + ['dataWarehouseSavedQueries', 'dataWarehouseSavedQueryMapById', 'dataWarehouseSavedQueriesLoading'], + databaseTableListLogic, + ['posthogTables', 'dataWarehouseTables', 'databaseLoading', 'views', 'viewsMapById'], + ], + actions: [editorSceneLogic, ['selectSchema'], dataWarehouseViewsLogic, ['deleteDataWarehouseSavedQuery']], + }), + selectors(({ actions }) => ({ + contents: [ + (s) => [ + s.relevantSavedQueries, + s.dataWarehouseSavedQueriesLoading, + s.relevantPosthogTables, + s.relevantDataWarehouseTables, + s.databaseLoading, + ], + ( + relevantSavedQueries, + dataWarehouseSavedQueriesLoading, + relevantPosthogTables, + relevantDataWarehouseTables, + databaseLoading + ) => [ + { + key: 'data-warehouse-sources', + noun: ['source', 'external source'], + loading: databaseLoading, + items: relevantDataWarehouseTables.map(([table, matches]) => ({ + key: table.id, + name: table.name, + url: '', + searchMatch: matches + ? { + matchingFields: matches.map((match) => match.key), + nameHighlightRanges: matches.find((match) => match.key === 'name')?.indices, + } + : null, + onClick: () => { + actions.selectSchema(table) + }, + })), + onAdd: () => { + router.actions.push(urls.pipeline(PipelineTab.Sources)) + }, + } as SidebarCategory, + { + key: 'data-warehouse-tables', + noun: ['table', 'tables'], + loading: databaseLoading, + items: relevantPosthogTables.map(([table, matches]) => ({ + key: table.id, + name: table.name, + url: '', + searchMatch: matches + ? { + matchingFields: matches.map((match) => match.key), + nameHighlightRanges: matches.find((match) => match.key === 'name')?.indices, + } + : null, + onClick: () => { + actions.selectSchema(table) + }, + })), + } as SidebarCategory, + { + key: 'data-warehouse-views', + noun: ['view', 'views'], + loading: dataWarehouseSavedQueriesLoading, + items: relevantSavedQueries.map(([savedQuery, matches]) => ({ + key: savedQuery.id, + name: savedQuery.name, + url: '', + searchMatch: matches + ? { + matchingFields: matches.map((match) => match.key), + nameHighlightRanges: matches.find((match) => match.key === 'name')?.indices, + } + : null, + onClick: () => { + actions.selectSchema(savedQuery) + }, + menuItems: [ + { + label: 'Edit view definition', + onClick: () => { + multitabEditorLogic({ + key: `hogQLQueryEditor/${router.values.location.pathname}`, + }).actions.createTab(savedQuery.query.query, savedQuery) + }, + }, + { + label: 'Delete', + status: 'danger', + onClick: () => { + actions.deleteDataWarehouseSavedQuery(savedQuery.id) + }, + }, + ], + })), + } as SidebarCategory, + ], + ], + activeListItemKey: [ + (s) => [s.activeScene, s.sceneParams], + (activeScene, sceneParams): [string, number] | null => { + return activeScene === Scene.DataWarehouse && sceneParams.params.id + ? ['saved-queries', parseInt(sceneParams.params.id)] + : null + }, + ], + relevantDataWarehouseTables: [ + (s) => [s.dataWarehouseTables, navigation3000Logic.selectors.searchTerm], + (dataWarehouseTables, searchTerm): [DatabaseSchemaDataWarehouseTable, FuseSearchMatch[] | null][] => { + if (searchTerm) { + return dataWarehouseTablesfuse + .search(searchTerm) + .map((result) => [result.item, result.matches as FuseSearchMatch[]]) + } + return dataWarehouseTables.map((table) => [table, null]) + }, + ], + relevantPosthogTables: [ + (s) => [s.posthogTables, navigation3000Logic.selectors.searchTerm], + (posthogTables, searchTerm): [DatabaseSchemaTable, FuseSearchMatch[] | null][] => { + if (searchTerm) { + return posthogTablesfuse + .search(searchTerm) + .map((result) => [result.item, result.matches as FuseSearchMatch[]]) + } + return posthogTables.map((table) => [table, null]) + }, + ], + relevantSavedQueries: [ + (s) => [s.dataWarehouseSavedQueries, navigation3000Logic.selectors.searchTerm], + (dataWarehouseSavedQueries, searchTerm): [DataWarehouseSavedQuery, FuseSearchMatch[] | null][] => { + if (searchTerm) { + return savedQueriesfuse + .search(searchTerm) + .map((result) => [result.item, result.matches as FuseSearchMatch[]]) + } + return dataWarehouseSavedQueries.map((savedQuery) => [savedQuery, null]) + }, + ], + })), + subscriptions({ + dataWarehouseTables: (dataWarehouseTables) => { + dataWarehouseTablesfuse.setCollection(dataWarehouseTables) + }, + posthogTables: (posthogTables) => { + posthogTablesfuse.setCollection(posthogTables) + }, + dataWarehouseSavedQueries: (dataWarehouseSavedQueries) => { + savedQueriesfuse.setCollection(dataWarehouseSavedQueries) + }, + }), +]) diff --git a/frontend/src/scenes/data-warehouse/editor/multitabEditorLogic.tsx b/frontend/src/scenes/data-warehouse/editor/multitabEditorLogic.tsx index 7a4a3d4e84e..cad1c656c0b 100644 --- a/frontend/src/scenes/data-warehouse/editor/multitabEditorLogic.tsx +++ b/frontend/src/scenes/data-warehouse/editor/multitabEditorLogic.tsx @@ -1,6 +1,6 @@ import { Monaco } from '@monaco-editor/react' -import { LemonDialog, LemonInput } from '@posthog/lemon-ui' -import { actions, kea, listeners, path, props, propsChanged, reducers, selectors } from 'kea' +import { LemonDialog, LemonInput, lemonToast } from '@posthog/lemon-ui' +import { actions, connect, kea, key, listeners, path, props, propsChanged, reducers, selectors } from 'kea' import { subscriptions } from 'kea-subscriptions' import { LemonField } from 'lib/lemon-ui/LemonField' import { ModelMarker } from 'lib/monaco/codeEditorLogic' @@ -9,6 +9,7 @@ import { editor, MarkerSeverity, Uri } from 'monaco-editor' import { dataNodeLogic } from '~/queries/nodes/DataNode/dataNodeLogic' import { performQuery } from '~/queries/query' import { HogLanguage, HogQLMetadata, HogQLMetadataResponse, HogQLNotice, HogQLQuery, NodeKind } from '~/queries/schema' +import { DataWarehouseSavedQuery } from '~/types' import { dataWarehouseViewsLogic } from '../saved_queries/dataWarehouseViewsLogic' import type { multitabEditorLogicType } from './multitabEditorLogicType' @@ -22,29 +23,41 @@ export interface MultitabEditorLogicProps { export const editorModelsStateKey = (key: string | number): string => `${key}/editorModelQueries` export const activemodelStateKey = (key: string | number): string => `${key}/activeModelUri` +export interface QueryTab { + uri: Uri + view?: DataWarehouseSavedQuery +} + export const multitabEditorLogic = kea([ path(['data-warehouse', 'editor', 'multitabEditorLogic']), props({} as MultitabEditorLogicProps), + key((props) => props.key), + connect({ + actions: [ + dataWarehouseViewsLogic, + ['deleteDataWarehouseSavedQuerySuccess', 'createDataWarehouseSavedQuerySuccess'], + ], + }), actions({ setQueryInput: (queryInput: string) => ({ queryInput }), updateState: true, runQuery: (queryOverride?: string) => ({ queryOverride }), setActiveQuery: (query: string) => ({ query }), - setTabs: (tabs: Uri[]) => ({ tabs }), - addTab: (tab: Uri) => ({ tab }), - createTab: () => null, - deleteTab: (tab: Uri) => ({ tab }), - removeTab: (tab: Uri) => ({ tab }), - selectTab: (tab: Uri) => ({ tab }), + setTabs: (tabs: QueryTab[]) => ({ tabs }), + addTab: (tab: QueryTab) => ({ tab }), + createTab: (query?: string, view?: DataWarehouseSavedQuery) => ({ query, view }), + deleteTab: (tab: QueryTab) => ({ tab }), + removeTab: (tab: QueryTab) => ({ tab }), + selectTab: (tab: QueryTab) => ({ tab }), setLocalState: (key: string, value: any) => ({ key, value }), initialize: true, saveAsView: true, - saveAsViewSuccess: (name: string) => ({ name }), + saveAsViewSubmit: (name: string) => ({ name }), reloadMetadata: true, setMetadata: (query: string, metadata: HogQLMetadataResponse) => ({ query, metadata }), }), - propsChanged(({ actions }, oldProps) => { - if (!oldProps.monaco && !oldProps.editor) { + propsChanged(({ actions, props }, oldProps) => { + if (!oldProps.monaco && !oldProps.editor && props.monaco && props.editor) { actions.initialize() } }), @@ -62,20 +75,26 @@ export const multitabEditorLogic = kea([ }, ], activeModelUri: [ - null as Uri | null, + null as QueryTab | null, { selectTab: (_, { tab }) => tab, }, ], + editingView: [ + null as DataWarehouseSavedQuery | null, + { + selectTab: (_, { tab }) => tab.view ?? null, + }, + ], allTabs: [ - [] as Uri[], + [] as QueryTab[], { addTab: (state, { tab }) => { const newTabs = [...state, tab] return newTabs }, removeTab: (state, { tab: tabToRemove }) => { - const newModels = state.filter((tab) => tab.toString() !== tabToRemove.toString()) + const newModels = state.filter((tab) => tab.uri.toString() !== tabToRemove.uri.toString()) return newModels }, setTabs: (_, { tabs }) => tabs, @@ -130,25 +149,32 @@ export const multitabEditorLogic = kea([ }, ], })), - listeners(({ values, props, actions }) => ({ - createTab: () => { + listeners(({ values, props, actions, asyncActions }) => ({ + createTab: ({ query = '', view }) => { let currentModelCount = 1 - const allNumbers = values.allTabs.map((tab) => parseInt(tab.path.split('/').pop() || '0')) + const allNumbers = values.allTabs.map((tab) => parseInt(tab.uri.path.split('/').pop() || '0')) while (allNumbers.includes(currentModelCount)) { currentModelCount++ } if (props.monaco) { const uri = props.monaco.Uri.parse(currentModelCount.toString()) - const model = props.monaco.editor.createModel('', 'hogQL', uri) + const model = props.monaco.editor.createModel(query, 'hogQL', uri) props.editor?.setModel(model) - actions.addTab(uri) - actions.selectTab(uri) + actions.addTab({ + uri, + view, + }) + actions.selectTab({ + uri, + view, + }) const queries = values.allTabs.map((tab) => { return { - query: props.monaco?.editor.getModel(tab)?.getValue() || '', - path: tab.path.split('/').pop(), + query: props.monaco?.editor.getModel(tab.uri)?.getValue() || '', + path: tab.uri.path.split('/').pop(), + view: uri.path === tab.uri.path ? view : tab.view, } }) actions.setLocalState(editorModelsStateKey(props.key), JSON.stringify(queries)) @@ -156,18 +182,20 @@ export const multitabEditorLogic = kea([ }, selectTab: ({ tab }) => { if (props.monaco) { - const model = props.monaco.editor.getModel(tab) + const model = props.monaco.editor.getModel(tab.uri) props.editor?.setModel(model) } - const path = tab.path.split('/').pop() + const path = tab.uri.path.split('/').pop() path && actions.setLocalState(activemodelStateKey(props.key), path) }, deleteTab: ({ tab: tabToRemove }) => { if (props.monaco) { - const model = props.monaco.editor.getModel(tabToRemove) - if (tabToRemove == values.activeModelUri) { - const indexOfModel = values.allTabs.findIndex((tab) => tab.toString() === tabToRemove.toString()) + 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) @@ -176,8 +204,9 @@ export const multitabEditorLogic = kea([ actions.removeTab(tabToRemove) const queries = values.allTabs.map((tab) => { return { - query: props.monaco?.editor.getModel(tab)?.getValue() || '', - path: tab.path.split('/').pop(), + query: props.monaco?.editor.getModel(tab.uri)?.getValue() || '', + path: tab.uri.path.split('/').pop(), + view: tab.view, } }) actions.setLocalState(editorModelsStateKey(props.key), JSON.stringify(queries)) @@ -197,14 +226,17 @@ export const multitabEditorLogic = kea([ }) const models = JSON.parse(allModelQueries || '[]') - const newModels: Uri[] = [] + 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) - newModels.push(uri) + newModels.push({ + uri, + view: model.view, + }) } }) @@ -221,9 +253,17 @@ export const multitabEditorLogic = kea([ actions.setQueryInput(val) actions.runQuery() } - uri && actions.selectTab(uri) + const activeView = newModels.find((tab) => tab.uri.path.split('/').pop() === activeModelUri)?.view + + uri && + actions.selectTab({ + uri, + view: activeView, + }) } else if (newModels.length) { - actions.selectTab(newModels[0]) + actions.selectTab({ + uri: newModels[0].uri, + }) } } else { const model = props.editor?.getModel() @@ -240,13 +280,23 @@ export const multitabEditorLogic = kea([ await breakpoint(100) const queries = values.allTabs.map((model) => { return { - query: props.monaco?.editor.getModel(model)?.getValue() || '', - path: model.path.split('/').pop(), + query: props.monaco?.editor.getModel(model.uri)?.getValue() || '', + path: model.uri.path.split('/').pop(), + view: model.view, } }) localStorage.setItem(editorModelsStateKey(props.key), JSON.stringify(queries)) }, runQuery: ({ queryOverride }) => { + if (values.activeQuery === queryOverride || values.activeQuery === values.queryInput) { + dataNodeLogic({ + key: values.activeTabKey, + query: { + kind: NodeKind.HogQLQuery, + query: queryOverride || values.queryInput, + }, + }).actions.loadData(true) + } actions.setActiveQuery(queryOverride || values.queryInput) }, saveAsView: async () => { @@ -261,10 +311,13 @@ export const multitabEditorLogic = kea([ errors: { viewName: (name) => (!name ? 'You must enter a name' : undefined), }, - onSubmit: ({ viewName }) => actions.saveAsViewSuccess(viewName), + onSubmit: async ({ viewName }) => { + await asyncActions.saveAsViewSubmit(viewName) + }, + shouldAwaitSubmit: true, }) }, - saveAsViewSuccess: async ({ name }) => { + saveAsViewSubmit: async ({ name }) => { const query: HogQLQuery = { kind: NodeKind.HogQLQuery, query: values.queryInput, @@ -290,11 +343,34 @@ export const multitabEditorLogic = kea([ breakpoint() actions.setMetadata(query, response) }, + deleteDataWarehouseSavedQuerySuccess: ({ payload: viewId }) => { + const tabToRemove = values.allTabs.find((tab) => tab.view?.id === viewId) + if (tabToRemove) { + actions.deleteTab(tabToRemove) + } + lemonToast.success('View deleted') + }, + 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() + } + }, + updateDataWarehouseSavedQuerySuccess: () => { + lemonToast.success('View updated') + }, })), subscriptions(({ props, actions, values }) => ({ activeModelUri: (activeModelUri) => { if (props.monaco) { - const _model = props.monaco.editor.getModel(activeModelUri) + const _model = props.monaco.editor.getModel(activeModelUri.uri) const val = _model?.getValue() actions.setQueryInput(val ?? '') actions.runQuery() @@ -313,7 +389,7 @@ export const multitabEditorLogic = kea([ }, })), selectors({ - activeTabKey: [(s) => [s.activeModelUri], (activeModelUri) => `hogQLQueryEditor/${activeModelUri?.path}`], + activeTabKey: [(s) => [s.activeModelUri], (activeModelUri) => `hogQLQueryEditor/${activeModelUri?.uri.path}`], isValidView: [(s) => [s.metadata], (metadata) => !!(metadata && metadata[1]?.isValidView)], hasErrors: [ (s) => [s.modelMarkers], diff --git a/frontend/src/scenes/data-warehouse/editor/sourceNavigatorLogic.ts b/frontend/src/scenes/data-warehouse/editor/sourceNavigatorLogic.ts deleted file mode 100644 index 406ae6e0d4e..00000000000 --- a/frontend/src/scenes/data-warehouse/editor/sourceNavigatorLogic.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { kea } from 'kea' - -import type { sourceNavigatorLogicType } from './sourceNavigatorLogicType' - -export const sourceNavigatorLogic = kea({ - path: ['scenes', 'data-warehouse', 'editor', 'sourceNavigatorLogic'], - actions: { - setWidth: (width: number) => ({ width }), - }, - reducers: { - navigatorWidth: [ - 200, - { - setWidth: (_, { width }: { width: number }) => width, - }, - ], - }, -}) diff --git a/frontend/src/scenes/data-warehouse/saved_queries/dataWarehouseViewsLogic.tsx b/frontend/src/scenes/data-warehouse/saved_queries/dataWarehouseViewsLogic.tsx index 10df0ed6c1f..37c744e633d 100644 --- a/frontend/src/scenes/data-warehouse/saved_queries/dataWarehouseViewsLogic.tsx +++ b/frontend/src/scenes/data-warehouse/saved_queries/dataWarehouseViewsLogic.tsx @@ -46,7 +46,7 @@ export const dataWarehouseViewsLogic = kea([ await api.dataWarehouseSavedQueries.delete(viewId) return values.dataWarehouseSavedQueries.filter((view) => view.id !== viewId) }, - updateDataWarehouseSavedQuery: async (view: DatabaseSchemaViewTable) => { + updateDataWarehouseSavedQuery: async (view: Partial & { id: string }) => { const newView = await api.dataWarehouseSavedQueries.update(view.id, view) return values.dataWarehouseSavedQueries.map((savedQuery) => { if (savedQuery.id === view.id) { diff --git a/frontend/src/scenes/data-warehouse/settings/source/SourceConfiguration.tsx b/frontend/src/scenes/data-warehouse/settings/source/SourceConfiguration.tsx index e4e36cdc54e..8df7e27c760 100644 --- a/frontend/src/scenes/data-warehouse/settings/source/SourceConfiguration.tsx +++ b/frontend/src/scenes/data-warehouse/settings/source/SourceConfiguration.tsx @@ -43,7 +43,7 @@ function UpdateSourceConnectionFormContainer(props: UpdateSourceConnectionFormCo <> Overwrite your existing configuration here
    - +