diff --git a/apps/studio/components/grid/components/header/Header.tsx b/apps/studio/components/grid/components/header/Header.tsx index dddd121196f..4fee83157d7 100644 --- a/apps/studio/components/grid/components/header/Header.tsx +++ b/apps/studio/components/grid/components/header/Header.tsx @@ -68,7 +68,7 @@ const Header = ({ sorts, filters, customHeader }: HeaderProps) => { return (
-
+
{customHeader ? ( <>{customHeader} ) : ( diff --git a/apps/studio/components/interfaces/Account/Preferences/DeleteAccountButton.tsx b/apps/studio/components/interfaces/Account/Preferences/DeleteAccountButton.tsx index dea8f8ccf88..23caa8ab8ac 100644 --- a/apps/studio/components/interfaces/Account/Preferences/DeleteAccountButton.tsx +++ b/apps/studio/components/interfaces/Account/Preferences/DeleteAccountButton.tsx @@ -25,6 +25,7 @@ import { FormLabel_Shadcn_, Form_Shadcn_, Input_Shadcn_, + Separator, } from 'ui' const setDeletionRequestFlag = () => { @@ -108,8 +109,8 @@ export const DeleteAccountButton = () => { Request to delete account - - + + {(organizations ?? []).length > 0 ? ( <> Leave all organizations before requesting account deletion @@ -128,25 +129,25 @@ export const DeleteAccountButton = () => { )} + + {isSuccess && ( <> {organizations.length > 0 ? ( - - - Before submitting an account deletion request, please ensure that your account is - not part of any organization. This can be done by leaving or deleting the - organizations that you are a part of. - - - + <> + + + Before submitting an account deletion request, please ensure that your account + is not part of any organization. This can be done by leaving or deleting the + organizations that you are a part of. + + + + + + ) : (
{ that can be accessed anywhere while in a project.
  • - + Move the GraphiQL interface the Database section of the dashboard{' '} + here.
  • diff --git a/apps/studio/components/interfaces/App/FeaturePreview/CLSPreview.tsx b/apps/studio/components/interfaces/App/FeaturePreview/CLSPreview.tsx index a88c1eddc96..57f156148f8 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/CLSPreview.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/CLSPreview.tsx @@ -2,6 +2,7 @@ import Image from 'next/image' import { useParams } from 'common' import { Markdown } from 'components/interfaces/Markdown' +import { InlineLink } from 'components/ui/InlineLink' import { BASE_PATH } from 'lib/constants' import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, WarningIcon } from 'ui' @@ -43,10 +44,8 @@ export const CLSPreview = () => {

    Enabling this preview will:

    diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx index 7695b58f998..e6bebb185d8 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx @@ -1,13 +1,34 @@ import { noop } from 'lodash' -import { PropsWithChildren, createContext, useContext, useEffect, useState } from 'react' import { LOCAL_STORAGE_KEYS } from 'lib/constants' import { EMPTY_OBJ } from 'lib/void' +import { PropsWithChildren, createContext, useContext, useEffect, useState } from 'react' import { APISidePanelPreview } from './APISidePanelPreview' import { CLSPreview } from './CLSPreview' -import InlineEditorPreview from './InlineEditorPreview' +import { InlineEditorPreview } from './InlineEditorPreview' +import { SqlEditorTabsPreview } from './SqlEditorTabs' +import { TableEditorTabsPreview } from './TableEditorTabs' export const FEATURE_PREVIEWS = [ + { + key: LOCAL_STORAGE_KEYS.UI_PREVIEW_INLINE_EDITOR, + name: 'Inline SQL Editor', + content: , + discussionsUrl: 'https://github.com/orgs/supabase/discussions/33690', + isNew: true, + }, + { + key: LOCAL_STORAGE_KEYS.UI_TABLE_EDITOR_TABS, + name: 'Table Editor Tabs', + content: , + isNew: true, + }, + { + key: LOCAL_STORAGE_KEYS.UI_SQL_EDITOR_TABS, + name: 'SQL Editor Tabs', + content: , + isNew: true, + }, { key: LOCAL_STORAGE_KEYS.UI_PREVIEW_API_SIDE_PANEL, name: 'Project API documentation', @@ -22,13 +43,6 @@ export const FEATURE_PREVIEWS = [ discussionsUrl: 'https://github.com/orgs/supabase/discussions/20295', isNew: false, }, - { - key: LOCAL_STORAGE_KEYS.UI_PREVIEW_INLINE_EDITOR, - name: 'Inline SQL Editor', - content: , - discussionsUrl: 'https://github.com/orgs/supabase/discussions/33690', - isNew: true, - }, ] type FeaturePreviewContextType = { @@ -90,3 +104,13 @@ export const useIsInlineEditorEnabled = () => { const { flags } = useFeaturePreviewContext() return flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_INLINE_EDITOR] } + +export const useIsTableEditorTabsEnabled = () => { + const { flags } = useFeaturePreviewContext() + return flags[LOCAL_STORAGE_KEYS.UI_TABLE_EDITOR_TABS] +} + +export const useIsSQLEditorTabsEnabled = () => { + const { flags } = useFeaturePreviewContext() + return flags[LOCAL_STORAGE_KEYS.UI_SQL_EDITOR_TABS] +} diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx index 295bc56f7c6..2555c74562f 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx @@ -2,28 +2,30 @@ import { ExternalLink, Eye, EyeOff, FlaskConical } from 'lucide-react' import Link from 'next/link' import { useEffect, useState } from 'react' +import { useParams } from 'common' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' +import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { useFlag } from 'hooks/ui/useFlag' +import { LOCAL_STORAGE_KEYS } from 'lib/constants' import { useAppStateSnapshot } from 'state/app-state' +import { removeTabsByEditor } from 'state/tabs' import { Badge, Button, Modal, ScrollArea, cn } from 'ui' import { FEATURE_PREVIEWS, useFeaturePreviewContext } from './FeaturePreviewContext' -import { useParams } from 'common' -import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' const FeaturePreviewModal = () => { + const { ref } = useParams() const snap = useAppStateSnapshot() + const org = useSelectedOrganization() const featurePreviewContext = useFeaturePreviewContext() const { mutate: sendEvent } = useSendEventMutation() - const { ref: projectRef } = useParams() - const org = useSelectedOrganization() - const enableFunctionsAssistant = useFlag('functionsAssistantV2') + + const isFeaturePreviewTabsTableEditorFlag = useFlag('featurePreviewTabsTableEditor') + const isFeaturePreviewTabsSqlEditorFlag = useFlag('featurePreviewSqlEditorTabs') const selectedFeaturePreview = snap.selectedFeaturePreview === '' ? FEATURE_PREVIEWS[0].key : snap.selectedFeaturePreview const [selectedFeatureKey, setSelectedFeatureKey] = useState(selectedFeaturePreview) - const isNotReleased = - selectedFeatureKey === 'supabase-ui-functions-assistant' && !enableFunctionsAssistant const { flags, onUpdateFlag } = featurePreviewContext const selectedFeature = FEATURE_PREVIEWS.find((preview) => preview.key === selectedFeatureKey) @@ -34,8 +36,15 @@ const FeaturePreviewModal = () => { sendEvent({ action: isSelectedFeatureEnabled ? 'feature_preview_disabled' : 'feature_preview_enabled', properties: { feature: selectedFeatureKey }, - groups: { project: projectRef ?? 'Unknown', organization: org?.slug ?? 'Unknown' }, + groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' }, }) + + if (selectedFeatureKey === LOCAL_STORAGE_KEYS.UI_TABLE_EDITOR_TABS) { + removeTabsByEditor(ref as string | undefined, 'table') + } + if (selectedFeatureKey === LOCAL_STORAGE_KEYS.UI_SQL_EDITOR_TABS) { + removeTabsByEditor(ref as string | undefined, 'sql') + } } function handleCloseFeaturePreviewModal() { @@ -43,8 +52,19 @@ const FeaturePreviewModal = () => { snap.setSelectedFeaturePreview(FEATURE_PREVIEWS[0].key) } + function isReleasedToPublic(feature: (typeof FEATURE_PREVIEWS)[number]) { + switch (feature.key) { + case LOCAL_STORAGE_KEYS.UI_TABLE_EDITOR_TABS: + return isFeaturePreviewTabsTableEditorFlag + case LOCAL_STORAGE_KEYS.UI_SQL_EDITOR_TABS: + return isFeaturePreviewTabsSqlEditorFlag + default: + return true + } + } // this modal can be triggered on other pages // Update local state when valtio state changes + useEffect(() => { if (snap.selectedFeaturePreview !== '') { setSelectedFeatureKey(snap.selectedFeaturePreview) @@ -55,7 +75,7 @@ const FeaturePreviewModal = () => { if (snap.showFeaturePreviewModal) { sendEvent({ action: 'feature_previews_clicked', - groups: { project: projectRef ?? 'Unknown', organization: org?.slug ?? 'Unknown' }, + groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' }, }) } }, [snap.showFeaturePreviewModal]) @@ -65,7 +85,7 @@ const FeaturePreviewModal = () => { hideFooter showCloseButton size="xlarge" - className="max-w-4xl" + className="!max-w-4xl" header="Dashboard feature previews" visible={snap.showFeaturePreviewModal} onCancel={handleCloseFeaturePreviewModal} @@ -74,7 +94,10 @@ const FeaturePreviewModal = () => {
    - {FEATURE_PREVIEWS.map((feature) => { + {FEATURE_PREVIEWS.filter((feature) => { + // filter out preview features that are not released to the public + return isReleasedToPublic(feature) + }).map((feature) => { const isEnabled = flags[feature.key] ?? false return ( @@ -113,15 +136,9 @@ const FeaturePreviewModal = () => { )} - {isNotReleased ? ( - - ) : ( - - )} +
    {selectedFeature?.content} diff --git a/apps/studio/components/interfaces/App/FeaturePreview/InlineEditorPreview.tsx b/apps/studio/components/interfaces/App/FeaturePreview/InlineEditorPreview.tsx index 24671a64cac..2409fe5ef4b 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/InlineEditorPreview.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/InlineEditorPreview.tsx @@ -3,7 +3,7 @@ import Image from 'next/image' import { BASE_PATH } from 'lib/constants' import { Admonition } from 'ui-patterns' -const InlineEditorPreview = () => { +export const InlineEditorPreview = () => { return (
    {
    ) } - -export default InlineEditorPreview diff --git a/apps/studio/components/interfaces/App/FeaturePreview/SqlEditorTabs.tsx b/apps/studio/components/interfaces/App/FeaturePreview/SqlEditorTabs.tsx new file mode 100644 index 00000000000..ee053af8657 --- /dev/null +++ b/apps/studio/components/interfaces/App/FeaturePreview/SqlEditorTabs.tsx @@ -0,0 +1,28 @@ +import { BASE_PATH } from 'lib/constants' +import Image from 'next/image' + +export const SqlEditorTabsPreview = () => { + return ( +
    + api-docs-side-panel-preview +

    + The SQL Editor now features tabs for improved navigation and organization. Conveniently go + between the queries that you're focused on, and collapse the sidebar for a bigger real + estate while writing your queries. +

    +
    +

    Enabling this preview will:

    +
      +
    • Enable opening of queries in the SQL Editor as tabs
    • +
    • Support closing of the navigation sidebar for a larger code editor space
    • +
    +
    +
    + ) +} diff --git a/apps/studio/components/interfaces/App/FeaturePreview/TableEditorTabs.tsx b/apps/studio/components/interfaces/App/FeaturePreview/TableEditorTabs.tsx new file mode 100644 index 00000000000..f16de5e442f --- /dev/null +++ b/apps/studio/components/interfaces/App/FeaturePreview/TableEditorTabs.tsx @@ -0,0 +1,28 @@ +import { BASE_PATH } from 'lib/constants' +import Image from 'next/image' + +export const TableEditorTabsPreview = () => { + return ( +
    + api-docs-side-panel-preview +

    + The Table Editor now features tabs for improved navigation and organization. Have multiple + tables opened across schemas and conveniently go across them without having to switch + schemas. Collapse the sidebar for a bigger real estate while browsing your data. +

    +
    +

    Enabling this preview will:

    +
      +
    • Enable opening of tables in the Table Editor as tabs
    • +
    • Support closing of the navigation sidebar for a larger data grid space
    • +
    +
    +
    + ) +} diff --git a/apps/studio/components/interfaces/App/RouteValidationWrapper.tsx b/apps/studio/components/interfaces/App/RouteValidationWrapper.tsx index 77e46296db1..561aae84fbf 100644 --- a/apps/studio/components/interfaces/App/RouteValidationWrapper.tsx +++ b/apps/studio/components/interfaces/App/RouteValidationWrapper.tsx @@ -6,7 +6,8 @@ import { useIsLoggedIn, useParams } from 'common' import { useOrganizationsQuery } from 'data/organizations/organizations-query' import { useProjectsQuery } from 'data/projects/projects-query' import useLatest from 'hooks/misc/useLatest' -import { DEFAULT_HOME, IS_PLATFORM } from 'lib/constants' +import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' +import { DEFAULT_HOME, IS_PLATFORM, LOCAL_STORAGE_KEYS } from 'lib/constants' import { useAppStateSnapshot } from 'state/app-state' // Ideally these could all be within a _middleware when we use Next 12 @@ -17,6 +18,11 @@ const RouteValidationWrapper = ({ children }: PropsWithChildren<{}>) => { const isLoggedIn = useIsLoggedIn() const snap = useAppStateSnapshot() + const [dashboardHistory, _, { isSuccess: isSuccessStorage }] = useLocalStorageQuery( + LOCAL_STORAGE_KEYS.DASHBOARD_HISTORY(ref ?? ''), + { editor: undefined, sql: undefined } + ) + /** * Array of urls/routes that should be ignored */ @@ -96,6 +102,14 @@ const RouteValidationWrapper = ({ children }: PropsWithChildren<{}>) => { } }, [ref, id]) + useEffect(() => { + // Load dashboard history into app state + if (isSuccessStorage && ref) { + snap.setDashboardHistory(ref, 'editor', dashboardHistory.editor) + snap.setDashboardHistory(ref, 'sql', dashboardHistory.sql) + } + }, [isSuccessStorage, ref]) + return <>{children} } diff --git a/apps/studio/components/interfaces/SQLEditor/MonacoEditor.tsx b/apps/studio/components/interfaces/SQLEditor/MonacoEditor.tsx index 07a3709f288..19d012a27e4 100644 --- a/apps/studio/components/interfaces/SQLEditor/MonacoEditor.tsx +++ b/apps/studio/components/interfaces/SQLEditor/MonacoEditor.tsx @@ -15,6 +15,7 @@ import { Admonition } from 'ui-patterns' import { untitledSnippetTitle } from './SQLEditor.constants' import type { IStandaloneCodeEditor } from './SQLEditor.types' import { createSqlSnippetSkeletonV2 } from './SQLEditor.utils' +import { makeActiveTabPermanent } from 'state/tabs' export type MonacoEditorProps = { id: string @@ -146,6 +147,9 @@ const MonacoEditor = ({ const debouncedSetSql = debounce((id, value) => snapV2.setSql(id, value), 1000) function handleEditorChange(value: string | undefined) { + // make active tab permanent + makeActiveTabPermanent(ref) + const snippetCheck = snapV2.snippets[id] if (id && value) { diff --git a/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx b/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx index ad37109f140..23a3e6b1fd6 100644 --- a/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx +++ b/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx @@ -13,7 +13,9 @@ import type { SqlSnippet } from 'data/content/sql-snippets-query' import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2' +import { createTabId, renameTab } from 'state/tabs' import { AiIconAnimation, Button, Form, Input, Modal } from 'ui' +import { useIsSQLEditorTabsEnabled } from '../App/FeaturePreview/FeaturePreviewContext' import { subscriptionHasHipaaAddon } from '../Billing/Subscription/Subscription.utils' export interface RenameQueryModalProps { @@ -35,6 +37,7 @@ const RenameQueryModal = ({ const snapV2 = useSqlEditorV2StateSnapshot() const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: organization?.slug }) const isSQLSnippet = snippet.type === 'sql' + const isSQLEditorTabsEnabled = useIsSQLEditorTabsEnabled() // Customers on HIPAA plans should not have access to Supabase AI const hasHipaaAddon = subscriptionHasHipaaAddon(subscription) @@ -103,7 +106,10 @@ const RenameQueryModal = ({ }) snapV2.renameSnippet({ id, name: nameInput, description: descriptionInput }) - + if (isSQLEditorTabsEnabled && ref) { + const tabId = createTabId('sql', { id }) + renameTab(ref, tabId, nameInput) + } toast.success('Successfully renamed snippet!') if (onComplete) onComplete() } catch (error: any) { diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx b/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx index 4c7b0f2b197..723e59cc656 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx @@ -9,6 +9,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { toast } from 'sonner' import { useParams } from 'common' +import ResizableAIWidget from 'components/ui/AIEditor/ResizableAIWidget' import { GridFooter } from 'components/ui/GridFooter' import { useSqlTitleGenerateMutation } from 'data/ai/sql-title-mutation' import { useEntityDefinitionsQuery } from 'data/database/entity-definitions-query' @@ -48,7 +49,6 @@ import { cn, } from 'ui' import { subscriptionHasHipaaAddon } from '../Billing/Subscription/Subscription.utils' -import ResizableAIWidget from 'components/ui/AIEditor/ResizableAIWidget' import { useSqlEditorDiff, useSqlEditorPrompt } from './hooks' import { RunQueryWarningModal } from './RunQueryWarningModal' import { diff --git a/apps/studio/components/interfaces/SQLEditor/SQLTemplates/SQLCard.tsx b/apps/studio/components/interfaces/SQLEditor/SQLTemplates/SQLCard.tsx deleted file mode 100644 index 0f0f9361fc7..00000000000 --- a/apps/studio/components/interfaces/SQLEditor/SQLTemplates/SQLCard.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import CardButton from 'components/ui/CardButton' -import { useState } from 'react' - -export interface SQLCardProps { - title: string - description: string - sql: string - onClick: (sql: string, title: string) => void -} - -const SQLCard = ({ title, description, sql, onClick }: SQLCardProps) => { - const [loading, setLoading] = useState(false) - - function handleOnClick() { - setLoading(true) - onClick(sql, title) - } - return ( - handleOnClick()} - description={description} - className="xl:h-48 p-6" - /> - ) -} - -export default SQLCard diff --git a/apps/studio/components/interfaces/SQLEditor/SQLTemplates/SQLQuickstarts.tsx b/apps/studio/components/interfaces/SQLEditor/SQLTemplates/SQLQuickstarts.tsx index f952613295b..4aec1576724 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLTemplates/SQLQuickstarts.tsx +++ b/apps/studio/components/interfaces/SQLEditor/SQLTemplates/SQLQuickstarts.tsx @@ -5,15 +5,16 @@ import { toast } from 'sonner' import { useParams } from 'common' import { SQL_TEMPLATES } from 'components/interfaces/SQLEditor/SQLEditor.queries' +import { ActionCard } from 'components/layouts/Tabs/ActionCard' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { useSelectedProject } from 'hooks/misc/useSelectedProject' import { uuidv4 } from 'lib/helpers' import { useProfile } from 'lib/profile' import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2' +import { cn, SQL_ICON } from 'ui' import { createSqlSnippetSkeletonV2 } from '../SQLEditor.utils' -import SQLCard from './SQLCard' -import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' const SQLQuickstarts = () => { const router = useRouter() @@ -51,6 +52,7 @@ const SQLQuickstarts = () => { }) snapV2.addSnippet({ projectRef: ref, snippet }) snapV2.addNeedsSaving(snippet.id) + router.push(`/project/${ref}/sql/${snippet.id}`) } catch (error: any) { toast.error(`Failed to create new query: ${error.message}`) @@ -58,28 +60,29 @@ const SQLQuickstarts = () => { } return ( -
    +
    -
    -

    Quickstarts

    - +
    +

    Quickstarts

    Click on any script to fill the query box, modify the script, then click Run.

    - {quickStart.map((x) => ( - ( + { - handleNewQuery(sql, title) + bgColor="bg-alternative border" + key={`action-card-${i}`} + icon={} + // sql={x.sql} + onClick={() => { + handleNewQuery(x.sql, x.title) sendEvent({ action: 'sql_editor_quickstart_clicked', - properties: { quickstartName: title }, + properties: { quickstartName: x.title }, groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' }, }) }} diff --git a/apps/studio/components/interfaces/SQLEditor/SQLTemplates/SQLTemplates.tsx b/apps/studio/components/interfaces/SQLEditor/SQLTemplates/SQLTemplates.tsx index aa0d35adf5b..f5f8d1fd6f2 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLTemplates/SQLTemplates.tsx +++ b/apps/studio/components/interfaces/SQLEditor/SQLTemplates/SQLTemplates.tsx @@ -6,14 +6,15 @@ import { toast } from 'sonner' import { useParams } from 'common' import { SQL_TEMPLATES } from 'components/interfaces/SQLEditor/SQLEditor.queries' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import { ActionCard } from 'components/layouts/Tabs/ActionCard' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { uuidv4 } from 'lib/helpers' import { useProfile } from 'lib/profile' import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2' +import { cn, SQL_ICON } from 'ui' import { createSqlSnippetSkeletonV2 } from '../SQLEditor.utils' -import SQLCard from './SQLCard' const SQLTemplates = () => { const router = useRouter() @@ -51,6 +52,7 @@ const SQLTemplates = () => { }) snapV2.addSnippet({ projectRef: ref, snippet }) snapV2.addNeedsSaving(snippet.id) + router.push(`/project/${ref}/sql/${snippet.id}`) } catch (error: any) { toast.error(`Failed to create new query: ${error.message}`) @@ -58,10 +60,10 @@ const SQLTemplates = () => { } return ( -
    +
    -
    -

    Scripts

    +
    +

    Scripts

    Quick scripts to run on your database.

    Click on any script to fill the query box, modify the script, then click @@ -69,17 +71,18 @@ const SQLTemplates = () => {

    - {sql.map((x) => ( - ( + { - handleNewQuery(sql, title) + bgColor="bg-alternative border" + icon={} + onClick={() => { + handleNewQuery(x.sql, x.title) sendEvent({ action: 'sql_editor_template_clicked', - properties: { templateName: title }, + properties: { templateName: x.title }, groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' }, }) }} diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx index 0affcf3395b..be65aa24e69 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx @@ -176,12 +176,17 @@ const UtilityActions = ({
    - + )} + - { - const { ref } = useParams() - const state = useDatabaseSelectorStateSnapshot() - const organization = useSelectedOrganization() - const snapV2 = useSqlEditorV2StateSnapshot() +const UtilityTabResults = forwardRef( + ({ id, isExecuting, isDisabled, isDebugging, onDebug }, htmlRef) => { + const { ref } = useParams() + const state = useDatabaseSelectorStateSnapshot() + const organization = useSelectedOrganization() + const snapV2 = useSqlEditorV2StateSnapshot() - const result = snapV2.results[id]?.[0] - const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: organization?.slug }) + const result = snapV2.results[id]?.[0] + const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: organization?.slug }) - // Customers on HIPAA plans should not have access to Supabase AI - const hasHipaaAddon = subscriptionHasHipaaAddon(subscription) + // Customers on HIPAA plans should not have access to Supabase AI + const hasHipaaAddon = subscriptionHasHipaaAddon(subscription) - const isTimeout = - result?.error?.message?.includes('canceling statement due to statement timeout') || - result?.error?.message?.includes('upstream request timeout') + const isTimeout = + result?.error?.message?.includes('canceling statement due to statement timeout') || + result?.error?.message?.includes('upstream request timeout') + const isNetWorkError = result?.error?.message?.includes('EHOSTUNREACH') - if (isExecuting) { - return ( -
    - -

    Running...

    -
    - ) - } else if (result?.error) { - const formattedError = (result.error?.formattedError?.split('\n') ?? []).filter( - (x: string) => x.length > 0 - ) - const readReplicaError = - state.selectedDatabaseId !== ref && - result.error.message.includes('in a read-only transaction') - const payloadTooLargeError = result.error.message.includes( - 'Query is too large to be run via the SQL Editor' - ) + if (isExecuting) { + return ( +
    + +

    Running...

    +
    + ) + } else if (result?.error) { + const formattedError = (result.error?.formattedError?.split('\n') ?? []).filter( + (x: string) => x.length > 0 + ) + const readReplicaError = + state.selectedDatabaseId !== ref && + result.error.message.includes('in a read-only transaction') + const payloadTooLargeError = result.error.message.includes( + 'Query is too large to be run via the SQL Editor' + ) - return ( -
    -
    - {isTimeout ? ( -
    -

    SQL query ran into an upstream timeout

    -

    - You can either{' '} - - optimize your query - - , or{' '} - - increase the statement timeout - - . -

    -
    - ) : ( -
    - {formattedError.length > 0 ? ( - formattedError.map((x: string, i: number) => ( -
    -                    {x}
    -                  
    - )) - ) : ( -

    Error: {result.error?.message}

    - )} - {!isTimeout && result.autoLimit && ( -

    - Note: A limit of {result.autoLimit} was applied to your query. If this was the - cause of a syntax error, try selecting "No limit" instead and re-run the query. -

    - )} - {readReplicaError && ( -

    - Note: Read replicas are for read only queries. Run write queries on the primary - database instead. -

    - )} - {payloadTooLargeError && ( -

    - Run this query by{' '} - +

    + {isTimeout ? ( +
    +

    SQL query ran into an upstream timeout

    +

    + You can either{' '} + - connecting to your database directly - - + optimize your query + + , or{' '} + + increase the statement timeout + .

    +
    + ) : ( +
    + {formattedError.length > 0 ? ( + formattedError.map((x: string, i: number) => ( +
    +                      {x}
    +                    
    + )) + ) : ( +

    Error: {result.error?.message}

    + )} + {!isTimeout && !isNetWorkError && result.autoLimit && ( +

    + Note: A limit of {result.autoLimit} was applied to your query. If this was the + cause of a syntax error, try selecting "No limit" instead and re-run the query. +

    + )} + {readReplicaError && ( +

    + Note: Read replicas are for read only queries. Run write queries on the primary + database instead. +

    + )} + {payloadTooLargeError && ( +

    + Run this query by{' '} + + connecting to your database directly + + + . +

    + )} +
    + )} + +
    + {readReplicaError && ( + + )} + {!hasHipaaAddon && ( + )}
    - )} - -
    - {readReplicaError && ( - - )} - {!hasHipaaAddon && ( - - )}
    -
    - ) - } else if (!result) { - return ( -
    -

    - Click Run to execute your query. -

    -
    - ) - } else if (result.rows.length <= 0) { - return ( -
    -

    Success. No rows returned

    -
    - ) + ) + } else if (!result) { + return ( +
    +

    + Click Run to execute your query. +

    +
    + ) + } else if (result.rows.length <= 0) { + return ( +
    +

    Success. No rows returned

    +
    + ) + } + + return } +) - return -} - +UtilityTabResults.displayName = 'UtilityTabResults' export default UtilityTabResults diff --git a/apps/studio/components/interfaces/Sidebar.tsx b/apps/studio/components/interfaces/Sidebar.tsx index 075cefa18ef..38f729f83b6 100644 --- a/apps/studio/components/interfaces/Sidebar.tsx +++ b/apps/studio/components/interfaces/Sidebar.tsx @@ -43,7 +43,10 @@ import { useSidebar, } from 'ui' import { useSetCommandMenuOpen } from 'ui-patterns' -import { useIsAPIDocsSidePanelEnabled } from './App/FeaturePreview/FeaturePreviewContext' +import { + useIsAPIDocsSidePanelEnabled, + useIsSQLEditorTabsEnabled, +} from './App/FeaturePreview/FeaturePreviewContext' import { ThemeDropdown } from './ThemeDropdown' import { UserDropdown } from './UserDropdown' @@ -264,7 +267,9 @@ function ProjectLinks() { const snap = useAppStateSnapshot() const isNewAPIDocsEnabled = useIsAPIDocsSidePanelEnabled() const { securityLints, errorLints } = useLints() + const showWarehouse = useFlag('warehouse') + const isSqlEditorTabsEnabled = useIsSQLEditorTabsEnabled() const activeRoute = router.pathname.split('/')[3] @@ -280,7 +285,9 @@ function ProjectLinks() { 'realtime:all', ]) - const toolRoutes = generateToolRoutes(ref, project) + const toolRoutes = generateToolRoutes(ref, project, { + sqlEditorTabs: isSqlEditorTabsEnabled, + }) const productRoutes = generateProductRoutes(ref, project, { auth: authEnabled, edgeFunctions: edgeFunctionsEnabled, diff --git a/apps/studio/components/interfaces/SignIn/LastSignInWrapper.tsx b/apps/studio/components/interfaces/SignIn/LastSignInWrapper.tsx index 83c41f29838..65dbd32eb76 100644 --- a/apps/studio/components/interfaces/SignIn/LastSignInWrapper.tsx +++ b/apps/studio/components/interfaces/SignIn/LastSignInWrapper.tsx @@ -1,5 +1,6 @@ import { LastSignInType, useLastSignIn } from 'hooks/misc/useLastSignIn' import { ReactNode } from 'react' + import { cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui' export function LastSignInWrapper({ diff --git a/apps/studio/components/interfaces/TableGridEditor/LoadingState.tsx b/apps/studio/components/interfaces/TableGridEditor/LoadingState.tsx index 6359dc19e17..1543b6ee06b 100644 --- a/apps/studio/components/interfaces/TableGridEditor/LoadingState.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/LoadingState.tsx @@ -1,4 +1,5 @@ import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' +import { Button, Skeleton } from 'ui' export const TableGridInnerLoadingState = () => { return ( @@ -11,9 +12,8 @@ export const TableGridInnerLoadingState = () => { export const TableGridSkeletonLoader = () => { return (
    -
    +
    -
    ) diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx index e5cd12a37ba..f1dc46ee78a 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx @@ -4,6 +4,8 @@ import { isEmpty, isUndefined, noop } from 'lodash' import { useState } from 'react' import { toast } from 'sonner' +import { useParams } from 'common' +import { useIsTableEditorTabsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import { useDatabasePublicationCreateMutation } from 'data/database-publications/database-publications-create-mutation' import { useDatabasePublicationsQuery } from 'data/database-publications/database-publications-query' @@ -11,6 +13,7 @@ import { useDatabasePublicationUpdateMutation } from 'data/database-publications import type { Constraint } from 'data/database/constraints-query' import type { ForeignKeyConstraint } from 'data/database/foreign-key-constraints-query' import { databaseKeys } from 'data/database/keys' +import { ENTITY_TYPE } from 'data/entity-types/entity-type-constants' import { entityTypeKeys } from 'data/entity-types/keys' import { tableEditorKeys } from 'data/table-editor/keys' import { isTableLike } from 'data/table-editor/table-editor-types' @@ -22,6 +25,7 @@ import { getTables } from 'data/tables/tables-query' import { useUrlState } from 'hooks/ui/useUrlState' import { useGetImpersonatedRole } from 'state/role-impersonation-state' import { useTableEditorStateSnapshot } from 'state/table-editor' +import { createTabId, renameTab } from 'state/tabs' import type { Dictionary } from 'types' import { SonnerProgress } from 'ui' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' @@ -63,7 +67,9 @@ const SidePanelEditor = ({ includeColumns = false, onTableCreated = noop, }: SidePanelEditorProps) => { + const { ref } = useParams() const snap = useTableEditorStateSnapshot() + const isTableEditorTabsEnabled = useIsTableEditorTabsEnabled() const [_, setParams] = useUrlState({ arrayKeys: ['filter', 'sort'] }) const queryClient = useQueryClient() @@ -478,6 +484,11 @@ const SidePanelEditor = ({ if (hasError) { toast(`Table ${table.name} has been updated, but there were some errors`, { id: toastId }) } else { + if (isTableEditorTabsEnabled && ref && payload.name) { + // [Joshen] Only table entities can be updated via the dashboard + const tabId = createTabId(ENTITY_TYPE.TABLE, { id: selectedTable.id }) + renameTab(ref, tabId, payload.name) + } toast.success(`Successfully updated ${table.name}!`, { id: toastId }) } } diff --git a/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx index 74a02132b00..2393b40235b 100644 --- a/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx @@ -13,9 +13,11 @@ import { isView, } from 'data/table-editor/table-editor-types' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { useSelectedProject } from 'hooks/misc/useSelectedProject' import { useUrlState } from 'hooks/ui/useUrlState' import { PROTECTED_SCHEMAS } from 'lib/constants/schemas' import { TableEditorTableStateContextProvider } from 'state/table-editor-table' +import { makeActiveTabPermanent } from 'state/tabs' import { TableGridSkeletonLoader } from './LoadingState' import NotFoundState from './NotFoundState' import SidePanelEditor from './SidePanelEditor/SidePanelEditor' @@ -31,6 +33,7 @@ const TableGridEditor = ({ selectedTable, }: TableGridEditorProps) => { const router = useRouter() + const project = useSelectedProject() const { ref: projectRef, id } = useParams() useLoadTableEditorStateFromLocalStorageIntoUrl({ @@ -74,37 +77,40 @@ const TableGridEditor = ({ */ return ( - - -

    - SQL Definition of {selectedTable.name}{' '} -

    -

    (Read only)

    -
    - ) : null - } - > - {(isViewSelected || isTableSelected) && selectedView === 'definition' && ( - - )} - - - makeActiveTabPermanent(project?.ref)}> + - + > + +

    + SQL Definition of {selectedTable.name}{' '} +

    +

    (Read only)

    +
    + ) : null + } + > + {(isViewSelected || isTableSelected) && selectedView === 'definition' && ( + + )} + + + + +
    ) } diff --git a/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx b/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx index 1d0bd06a8f1..f5d8a800ebc 100644 --- a/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx +++ b/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx @@ -1,5 +1,6 @@ import { Blocks, FileText, Lightbulb, List, Settings } from 'lucide-react' +import { ICON_SIZE, ICON_STROKE_WIDTH } from 'components/interfaces/Sidebar' import type { Route } from 'components/ui/ui.types' import { EditorIndexPageLink } from 'data/prefetchers/project.$ref.editor' import type { Project } from 'data/projects/project-detail-query' @@ -14,9 +15,13 @@ import { TableEditor, } from 'icons' import { IS_PLATFORM, PROJECT_STATUS } from 'lib/constants' -import { ICON_SIZE, ICON_STROKE_WIDTH } from 'components/interfaces/Sidebar' -export const generateToolRoutes = (ref?: string, project?: Project): Route[] => { +export const generateToolRoutes = ( + ref?: string, + project?: Project, + features?: { sqlEditorTabs: boolean } +): Route[] => { + const { sqlEditorTabs } = features ?? {} const isProjectBuilding = project?.status === PROJECT_STATUS.COMING_UP const buildingUrl = `/project/${ref}` @@ -34,7 +39,8 @@ export const generateToolRoutes = (ref?: string, project?: Project): Route[] => icon: , link: !IS_PLATFORM ? `/project/${ref}/sql/1` - : ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/sql/new`), + : ref && + (isProjectBuilding ? buildingUrl : `/project/${ref}/sql${!!sqlEditorTabs ? '' : '/new'}`), }, ] } diff --git a/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx b/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx index 198ef2e02c1..074bf251ac3 100644 --- a/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx +++ b/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx @@ -1,4 +1,8 @@ import { useParams } from 'common' +import { + useIsSQLEditorTabsEnabled, + useIsTableEditorTabsEnabled, +} from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import ProjectAPIDocs from 'components/interfaces/ProjectAPIDocs/ProjectAPIDocs' import { AIAssistantPanel } from 'components/ui/AIAssistantPanel/AIAssistantPanel' import AISettingsModal from 'components/ui/AISettingsModal' @@ -18,6 +22,7 @@ import { useDatabaseSelectorStateSnapshot } from 'state/database-selector' import { cn, ResizableHandle, ResizablePanel, ResizablePanelGroup } from 'ui' import MobileSheetNav from 'ui-patterns/MobileSheetNav/MobileSheetNav' import EnableBranchingModal from '../AppLayout/EnableBranchingButton/EnableBranchingModal' +import { useEditorType } from '../editors/EditorsLayout.hooks' import BuildingState from './BuildingState' import ConnectingState from './ConnectingState' import LoadingState from './LoadingState' @@ -90,9 +95,20 @@ const ProjectLayout = forwardRef diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorLayout.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorLayout.tsx index 931727c85b2..52adbcfd5c2 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorLayout.tsx +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorLayout.tsx @@ -1,28 +1,17 @@ import { OngoingQueriesPanel } from 'components/interfaces/SQLEditor/OngoingQueriesPanel' import { withAuth } from 'hooks/misc/withAuth' -import { ReactNode, useMemo } from 'react' -import ProjectLayout from '../ProjectLayout/ProjectLayout' -import { SQLEditorMenu } from './SQLEditorMenu' +import { ReactNode } from 'react' export interface SQLEditorLayoutProps { - title: string children: ReactNode } -const SQLEditorLayout = ({ title, children }: SQLEditorLayoutProps) => { - const productMenu = useMemo(() => , []) - +const SQLEditorLayout = ({ children }: SQLEditorLayoutProps) => { return ( - + <> {children} - + ) } diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx index 865976ba24d..b32e36a77a0 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx @@ -1,9 +1,5 @@ -import { Eye, EyeOffIcon, Heart, Unlock } from 'lucide-react' -import { useRouter } from 'next/router' -import { useEffect, useMemo, useState } from 'react' -import { toast } from 'sonner' - import { useParams } from 'common' +import { useIsSQLEditorTabsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import DownloadSnippetModal from 'components/interfaces/SQLEditor/DownloadSnippetModal' import { MoveQueryModal } from 'components/interfaces/SQLEditor/MoveQueryModal' import RenameQueryModal from 'components/interfaces/SQLEditor/RenameQueryModal' @@ -11,6 +7,7 @@ import { untitledSnippetTitle } from 'components/interfaces/SQLEditor/SQLEditor. import { createSqlSnippetSkeletonV2 } from 'components/interfaces/SQLEditor/SQLEditor.utils' import { EmptyPrivateQueriesPanel } from 'components/layouts/SQLEditorLayout/PrivateSqlSnippetEmpty' import EditorMenuListSkeleton from 'components/layouts/TableEditorLayout/EditorMenuListSkeleton' +import { sqlEditorTabsCleanup } from 'components/layouts/Tabs/Tabs.utils' import { useContentCountQuery } from 'data/content/content-count-query' import { useContentDeleteMutation } from 'data/content/content-delete-mutation' import { getContentById } from 'data/content/content-id-query' @@ -23,11 +20,16 @@ import { useSelectedProject } from 'hooks/misc/useSelectedProject' import { LOCAL_STORAGE_KEYS } from 'lib/constants' import { useProfile } from 'lib/profile' import uuidv4 from 'lib/uuid' +import { Eye, EyeOffIcon, Heart, Unlock } from 'lucide-react' +import { useRouter } from 'next/router' +import { useEffect, useMemo, useState } from 'react' +import { toast } from 'sonner' import { SnippetWithContent, useSnippetFolders, useSqlEditorV2StateSnapshot, } from 'state/sql-editor-v2' +import { createTabId, getTabsStore, makeTabPermanent, removeTabs } from 'state/tabs' import { SqlSnippets } from 'types' import { Separator, TreeView } from 'ui' import { @@ -38,6 +40,7 @@ import { InnerSideMenuSeparator, } from 'ui-patterns' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' +import { useSnapshot } from 'valtio' import SQLEditorLoadingSnippets from './SQLEditorLoadingSnippets' import { formatFolderResponseForTreeView, getLastItemIds, ROOT_NODE } from './SQLEditorNav.utils' import { SQLEditorTreeViewItem } from './SQLEditorTreeViewItem' @@ -51,11 +54,16 @@ const DEFAULT_SECTION_STATE: SectionState = { shared: false, favorite: false, pr export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { const router = useRouter() + const { ref: projectRef, id } = useParams() + const { profile } = useProfile() const project = useSelectedProject() - const { ref: projectRef, id } = useParams() const snapV2 = useSqlEditorV2StateSnapshot() - // const tabStore = getTabsStore(projectRef) + + const tabStore = getTabsStore(projectRef) + const tabs = useSnapshot(tabStore) + const isSQLEditorTabsEnabled = useIsSQLEditorTabsEnabled() + const [sectionVisibility, setSectionVisibility] = useLocalStorage( LOCAL_STORAGE_KEYS.SQL_EDITOR_SECTION_STATE(projectRef ?? ''), DEFAULT_SECTION_STATE @@ -268,6 +276,11 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { [projectSnippetsTreeState] ) + const allSnippetsInView = [ + ...(privateSnippetsPages?.pages.flatMap((x) => x.contents) ?? []), + ...(sharedSqlSnippetsData?.pages.flatMap((x) => x.contents) ?? []), + ] + // ========================== // Snippet mutations from RQ // ========================== @@ -279,6 +292,14 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { }) const { mutate: deleteContent, isLoading: isDeleting } = useContentDeleteMutation({ + onSuccess: (data) => { + if (isSQLEditorTabsEnabled) { + // Update Tabs state - currently unknown how to differentiate between sql and non-sql content + // so we're just deleting all tabs for with matching IDs + const tabIds = data.map((id) => createTabId('sql', { id })) + removeTabs(projectRef, tabIds) + } + }, onError: (error, data) => { if (error.message.includes('Contents not found')) { postDeleteCleanup(data.ids) @@ -491,12 +512,8 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { if (projectRef && privateSnippetsPages) { privateSnippetsPages.pages.forEach((page) => { page.contents?.forEach((snippet: Snippet) => { - snapV2.addSnippet({ - projectRef, - snippet, - }) + snapV2.addSnippet({ projectRef, snippet }) }) - page.folders?.forEach((folder: SnippetFolder) => snapV2.addFolder({ projectRef, folder })) }) } @@ -507,26 +524,27 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { favoriteSqlSnippetsData.pages.forEach((page) => { page.contents?.forEach((snippet) => { - snapV2.addSnippet({ - projectRef, - snippet, - }) + snapV2.addSnippet({ projectRef, snippet }) }) }) - }, [projectRef, privateSnippetsPages?.pages]) + }, [projectRef, favoriteSqlSnippetsData?.pages]) useEffect(() => { if (projectRef === undefined || !isSharedSqlSnippetsSuccess) return sharedSqlSnippetsData.pages.forEach((page) => { page.contents?.forEach((snippet) => { - snapV2.addSnippet({ - projectRef, - snippet, - }) + snapV2.addSnippet({ projectRef, snippet }) }) }) - }, [projectRef, privateSnippetsPages?.pages]) + }, [projectRef, sharedSqlSnippetsData?.pages]) + + useEffect(() => { + if (projectRef && isSuccess && isSharedSqlSnippetsSuccess && isSQLEditorTabsEnabled) { + sqlEditorTabsCleanup({ ref: projectRef, snippets: allSnippetsInView as any }) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isSuccess, isSharedSqlSnippetsSuccess, allSnippetsInView]) return ( <> @@ -541,7 +559,9 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { title={`Shared ${numProjectSnippets > 0 ? ` (${numProjectSnippets})` : ''}`} /> - {numProjectSnippets === 0 ? ( + {isLoadingSharedSqlSnippets ? ( + + ) : numProjectSnippets === 0 ? ( { data={projectSnippetsTreeState} aria-label="project-level-snippets" nodeRenderer={({ element, ...props }) => { - const isActive = element.metadata?.id === id + const isOpened = + isSQLEditorTabsEnabled && + Object.values(tabs.tabsMap).some( + (tab) => tab.metadata?.sqlId === element.metadata?.id + ) + const tabId = createTabId('sql', { + id: element?.metadata?.id as unknown as Snippet['id'], + }) + const isPreview = isSQLEditorTabsEnabled && tabStore.previewTabId === tabId + const isActive = !isPreview && element.metadata?.id === id + return ( { + e.preventDefault() + makeTabPermanent(projectRef, tabId) + }} element={element} onSelectDelete={() => { setShowDeleteModal(true) @@ -619,11 +655,26 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { data={favoritesTreeState} aria-label="favorite-snippets" nodeRenderer={({ element, ...props }) => { - const isActive = element.metadata?.id === id + const isOpened = + isSQLEditorTabsEnabled && + Object.values(tabs.tabsMap).some( + (tab) => tab.metadata?.sqlId === element.metadata?.id + ) + const tabId = createTabId('sql', { + id: element?.metadata?.id as unknown as Snippet['id'], + }) + const isPreview = isSQLEditorTabsEnabled && tabStore.previewTabId === tabId + const isActive = !isPreview && element.metadata?.id === id return ( { + e.preventDefault() + makeTabPermanent(projectRef, tabId) + }} element={element} onSelectDelete={() => { setShowDeleteModal(true) @@ -692,13 +743,24 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { }} expandedIds={expandedFolderIds} nodeRenderer={({ element, ...props }) => { - const isActive = element.metadata?.id === id + const isOpened = + isSQLEditorTabsEnabled && + Object.values(tabs.tabsMap).some( + (tab) => tab.metadata?.sqlId === element.metadata?.id + ) + const tabId = createTabId('sql', { + id: element?.metadata?.id as unknown as Snippet['id'], + }) + const isPreview = isSQLEditorTabsEnabled && tabStore.previewTabId === tabId + const isActive = !isPreview && element.metadata?.id === id return ( 1} isLastItem={privateSnippetsLastItemIds.has(element.id as string)} status={props.isBranch ? snapV2.folders[element.id].status : 'idle'} @@ -764,6 +826,10 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { [element.id as string]: { snippets, isLoading }, })) }} + onDoubleClick={(e) => { + e.preventDefault() + makeTabPermanent(projectRef, tabId) + }} /> ) }} diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorTreeViewItem.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorTreeViewItem.tsx index cef866cb945..8c97f4303bf 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorTreeViewItem.tsx +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorTreeViewItem.tsx @@ -1,8 +1,3 @@ -import { Copy, Download, Edit, ExternalLink, Lock, Move, Plus, Share, Trash } from 'lucide-react' -import Link from 'next/link' -import { useRouter } from 'next/router' -import { ComponentProps, useEffect } from 'react' - import { PermissionAction } from '@supabase/shared-types/out/constants' import { IS_PLATFORM } from 'common' import { useParams } from 'common/hooks/useParams' @@ -11,6 +6,10 @@ import { Snippet } from 'data/content/sql-folders-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import useLatest from 'hooks/misc/useLatest' import { useProfile } from 'lib/profile' +import { Copy, Download, Edit, ExternalLink, Lock, Move, Plus, Share, Trash } from 'lucide-react' +import Link from 'next/link' +import { useRouter } from 'next/router' +import { ComponentProps, useEffect } from 'react' import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2' import { Button, @@ -76,6 +75,7 @@ export const SQLEditorTreeViewItem = ({ sort, name, onFolderContentsChange, + ...props }: SQLEditorTreeViewItemProps) => { const router = useRouter() const { id, ref: projectRef } = useParams() @@ -164,6 +164,7 @@ export const SQLEditorTreeViewItem = ({ isExpanded={isExpanded} isBranch={isBranch} isSelected={isSelected} + isPreview={props.isPreview} isEditing={isEditing} isLoading={(isEnabled && isLoading) || isSaving} onEditSubmit={(value) => { @@ -184,11 +185,11 @@ export const SQLEditorTreeViewItem = ({ if (isEditing) { return } - // When the item is a folder, we want to expand/close it onClick(e) } }} + {...props} name={element.name} xPadding={16} /> diff --git a/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx b/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx index 52abdfb2bb9..f6117f9115a 100644 --- a/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx +++ b/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx @@ -3,6 +3,7 @@ import { Clipboard, Copy, Download, Edit, Lock, MoreHorizontal, Trash, Unlock } import Link from 'next/link' import Papa from 'papaparse' import { toast } from 'sonner' +import { useSnapshot } from 'valtio' import { IS_PLATFORM } from 'common' import { @@ -10,6 +11,7 @@ import { MAX_EXPORT_ROW_COUNT_MESSAGE, } from 'components/grid/components/header/Header' import { parseSupaTable } from 'components/grid/SupabaseGrid.utils' +import { useIsTableEditorTabsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { formatTableRowsToSQL, getEntityLintDetails, @@ -26,6 +28,7 @@ import { fetchAllTableRows } from 'data/table-rows/table-rows-query' import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState' import { copyToClipboard } from 'lib/helpers' import { useTableEditorStateSnapshot } from 'state/table-editor' +import { createTabId, getTabsStore, makeTabPermanent } from 'state/tabs' import { cn, DropdownMenu, @@ -42,7 +45,6 @@ import { TreeViewItemVariant, } from 'ui' import { useProjectContext } from '../ProjectLayout/ProjectContext' - export interface EntityListItemProps { id: number | string projectRef: string @@ -61,6 +63,14 @@ const EntityListItem: ItemRenderer = ({ const snap = useTableEditorStateSnapshot() const { selectedSchema } = useQuerySchemaState() + // For tabs preview flag logic + const isTableEditorTabsEnabled = useIsTableEditorTabsEnabled() + const tabId = createTabId(entity.type, { id: entity.id }) + const tabStore = getTabsStore(projectRef) + const isPreview = isTableEditorTabsEnabled ? tabStore.previewTabId === tabId : false + + const tabs = useSnapshot(tabStore) + const isOpened = Object.values(tabs.tabsMap).some((tab) => tab.metadata?.tableId === entity.id) const isActive = Number(id) === entity.id const canEdit = isActive && !isLocked @@ -214,10 +224,17 @@ const EntityListItem: ItemRenderer = ({ aria-label={`View ${entity.name}`} className={cn( TreeViewItemVariant({ - isSelected: isActive, + isSelected: isActive && !isPreview, + isOpened: isOpened && !isPreview, + isPreview, }), 'px-4' )} + onDoubleClick={(e) => { + e.preventDefault() + const tabId = createTabId(entity.type, { id: entity.id }) + makeTabPermanent(projectRef, tabId) + }} > <> {isActive &&
    } diff --git a/apps/studio/components/layouts/TableEditorLayout/TableEditorLayout.tsx b/apps/studio/components/layouts/TableEditorLayout/TableEditorLayout.tsx index 62ba020df5c..f13ca0793be 100644 --- a/apps/studio/components/layouts/TableEditorLayout/TableEditorLayout.tsx +++ b/apps/studio/components/layouts/TableEditorLayout/TableEditorLayout.tsx @@ -1,17 +1,13 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { PropsWithChildren, useMemo } from 'react' - import NoPermission from 'components/ui/NoPermission' import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions' +import { PropsWithChildren } from 'react' import { ProjectLayoutWithAuth } from '../ProjectLayout/ProjectLayout' -import TableEditorMenu from './TableEditorMenu' const TableEditorLayout = ({ children }: PropsWithChildren<{}>) => { const isPermissionsLoaded = usePermissionsLoaded() const canReadTables = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'tables') - const tableEditorMenu = useMemo(() => , []) - if (isPermissionsLoaded && !canReadTables) { return ( @@ -20,16 +16,7 @@ const TableEditorLayout = ({ children }: PropsWithChildren<{}>) => { ) } - return ( - - {children} - - ) + return <>{children} } export default TableEditorLayout diff --git a/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx b/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx index 4e331ac57bb..399b7634983 100644 --- a/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx +++ b/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx @@ -6,6 +6,7 @@ import { useEffect, useMemo, useState } from 'react' import { useParams } from 'common' import { useBreakpoint } from 'common/hooks/useBreakpoint' +import { useIsTableEditorTabsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { ProtectedSchemaModal } from 'components/interfaces/Database/ProtectedSchemaWarning' import EditorMenuListSkeleton from 'components/layouts/TableEditorLayout/EditorMenuListSkeleton' import AlertError from 'components/ui/AlertError' @@ -40,16 +41,18 @@ import { InnerSideBarFilters, } from 'ui-patterns/InnerSideMenu' import { useProjectContext } from '../ProjectLayout/ProjectContext' +import { tableEditorTabsCleanUp } from '../Tabs/Tabs.utils' import EntityListItem from './EntityListItem' import { TableMenuEmptyState } from './TableMenuEmptyState' const TableEditorMenu = () => { - const { id: _id } = useParams() + const { ref, id: _id } = useParams() const router = useRouter() const id = _id ? Number(_id) : undefined const snap = useTableEditorStateSnapshot() const { selectedSchema, setSelectedSchema } = useQuerySchemaState() const isMobile = useBreakpoint() + const isTableEditorTabsEnabled = useIsTableEditorTabsEnabled() const [showModal, setShowModal] = useState(false) const [searchText, setSearchText] = useState('') @@ -114,6 +117,13 @@ const TableEditorMenu = () => { } }, [selectedTable?.schema]) + useEffect(() => { + // Clean up tabs + recent items for any tables that might have been removed outside of the dashboard session + if (isTableEditorTabsEnabled && ref && entityTypes) { + tableEditorTabsCleanUp({ ref, schemas: [selectedSchema], entities: entityTypes }) + } + }, [entityTypes]) + return ( <>
    diff --git a/apps/studio/components/layouts/Tabs.tsx b/apps/studio/components/layouts/Tabs.tsx new file mode 100644 index 00000000000..760ede2f516 --- /dev/null +++ b/apps/studio/components/layouts/Tabs.tsx @@ -0,0 +1,324 @@ +import { + DndContext, + DragEndEvent, + DragOverlay, + PointerSensor, + useSensor, + useSensors, +} from '@dnd-kit/core' +import { horizontalListSortingStrategy, SortableContext, useSortable } from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' +import { AnimatePresence, motion } from 'framer-motion' +import { Plus, X } from 'lucide-react' +import { useRouter } from 'next/router' +import { useMemo } from 'react' +import { useSnapshot } from 'valtio' + +import { useParams } from 'common' +import { EntityTypeIcon } from 'components/ui/EntityTypeIcon' +import { useAppStateSnapshot } from 'state/app-state' +import { + editorEntityTypes, + getTabsStore, + handleTabClose, + handleTabDragEnd, + handleTabNavigation, + makeTabPermanent, + type Tab, +} from 'state/tabs' +import { cn, Tabs_Shadcn_, TabsList_Shadcn_, TabsTrigger_Shadcn_ } from 'ui' +import { useEditorType } from './editors/EditorsLayout.hooks' +import { CollapseButton } from './Tabs/CollapseButton' + +/** + * Individual draggable tab component that handles: + * - Drag + * - Drop functionality + * - Dynamic schema name display + * - Tab label animations + * - Close button interactions + */ +const SortableTab = ({ + tab, + index, + openTabs, + onClose, +}: { + tab: Tab + index: number + openTabs: Tab[] + onClose: (id: string) => void +}) => { + const router = useRouter() + const { ref } = useParams() + const currentSchema = (router.query.schema as string) || 'public' + + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: tab.id, + }) + + const style = { + transform: CSS.Transform.toString(transform), + transition, + zIndex: isDragging ? 1 : 0, + } + + // Update schema visibility check to include URL param comparison + const shouldShowSchema = useMemo(() => { + // For both table and schema tabs, show schema if: + // Any tab has a different schema than the current schema parameter + if (tab.type === 'r') { + const anyTabHasDifferentSchema = openTabs + .filter((t) => t.type === 'r') + .some((t) => t.metadata?.schema !== currentSchema) + + return anyTabHasDifferentSchema + } + + return false + }, [openTabs, currentSchema, tab.type]) + + // Create a motion version of TabsTrigger while preserving all functionality + // const MotionTabsTrigger = motion(TabsTrigger_Shadcn_) + + return ( + + makeTabPermanent(ref, tab.id)} + className={cn( + 'flex items-center gap-2 px-3 text-xs', + 'bg-dash-sidebar/50 dark:bg-surface-100/50', + 'data-[state=active]:bg-dash-sidebar dark:data-[state=active]:bg-surface-100', + 'border-b border-default', + // bottom border active rule + 'data-[state=active]:border-b-background-dash-sidebar dark:data-[state=active]:border-b-background-surface-100', + 'relative group h-full', + 'hover:bg-surface-300 dark:hover:bg-surface-100', + tab.isPreview && 'italic font-light' // Optional: style preview tabs differently + )} + {...listeners} + > + +
    + + {shouldShowSchema && ( + + {tab?.metadata?.schema}. + + )} + + {tab.label || 'Untitled'} +
    + { + e.preventDefault() + e.stopPropagation() + }} + className="ml-1 opacity-0 group-hover:opacity-100 hover:bg-200 rounded-sm cursor-pointer" + onMouseDown={(e) => { + e.preventDefault() + e.stopPropagation() + }} + onPointerDown={(e) => { + e.preventDefault() + e.stopPropagation() + onClose(tab.id) + }} + > + + +
    + + {index < openTabs.length && ( +
    + )} + + ) +} + +const TabPreview = ({ tab }: { tab: string }) => { + const { ref } = useParams() + const store = getTabsStore(ref) + const tabs = useSnapshot(store) + + const tabData = tabs.tabsMap[tab] + + if (!tabData) return null + + return ( + + + {tabData.label || 'Untitled'} +
    + + ) +} + +export function Tabs() { + const { ref } = useParams() + const router = useRouter() + const appSnap = useAppStateSnapshot() + + const editor = useEditorType() + const store = getTabsStore(ref) + const tabs = useSnapshot(store) + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 1, // Start with a very small distance + }, + }) + ) + + const openTabs = tabs.openTabs + .map((id) => tabs.tabsMap[id]) + .filter((tab) => tab !== undefined) as Tab[] + + const hasNewTab = router.asPath.includes('/new') + + // Filter by editor type - only show SQL tabs for SQL editor and table tabs for table editor + const editorTabs = !!editor + ? openTabs.filter((tab) => editorEntityTypes[editor]?.includes(tab.type)) + : [] + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event + if (!over || active.id === over.id) return + + const oldIndex = tabs.openTabs.indexOf(active.id.toString()) + const newIndex = tabs.openTabs.indexOf(over.id.toString()) + + if (oldIndex !== newIndex) { + handleTabDragEnd(ref, oldIndex, newIndex, active.id.toString(), router) + } + } + + const handleClose = (id: string) => { + const onClearDashboardHistory = () => { + if (ref && editor) { + appSnap.setDashboardHistory(ref, editor === 'table' ? 'editor' : editor, undefined) + } + } + handleTabClose({ ref, id, router, editor, onClearDashboardHistory }) + } + + const handleTabChange = (id: string) => { + handleTabNavigation(ref, id, router) + } + + return ( + + + + + tab.id)} + strategy={horizontalListSortingStrategy} + > + {editorTabs.map((tab, index) => ( + handleClose(tab.id)} + /> + ))} + + + {/* Non-draggable new tab */} + {hasNewTab && ( + <> + + +
    + New +
    + { + e.preventDefault() + e.stopPropagation() + }} + className="ml-1 opacity-0 group-hover:opacity-100 hover:bg-200 rounded-sm cursor-pointer" + onMouseDown={(e) => { + e.preventDefault() + e.stopPropagation() + }} + onPointerDown={(e) => { + e.preventDefault() + e.stopPropagation() + handleClose('new') + }} + > + + {' '} +
    + + + )} + + + {!hasNewTab && ( + + router.push( + `/project/${router.query.ref}/${editor === 'table' ? 'editor' : 'sql'}/new?skip=true` + ) + } + initial={{ opacity: 0, scale: 0.8, x: -10 }} + animate={{ opacity: 1, scale: 1, x: 0 }} + transition={{ duration: 0.2 }} + > + + + )} + +
    + + + + {tabs.activeTab ? : null} + + + ) +} diff --git a/apps/studio/components/layouts/Tabs/ActionCard.tsx b/apps/studio/components/layouts/Tabs/ActionCard.tsx new file mode 100644 index 00000000000..14f5472b2df --- /dev/null +++ b/apps/studio/components/layouts/Tabs/ActionCard.tsx @@ -0,0 +1,34 @@ +import { Badge, Card } from 'ui' + +export const ActionCard = (card: { + icon: JSX.Element + title: string + description: string + bgColor: string + isBeta?: boolean + onClick?: () => void +}) => { + return ( + +
    + {card.isBeta && ( + + Coming soon + + )} +
    + {card.icon} +
    +
    +

    {card.title}

    +

    {card.description}

    +
    +
    +
    + ) +} diff --git a/apps/studio/components/layouts/Tabs/CollapseButton.tsx b/apps/studio/components/layouts/Tabs/CollapseButton.tsx new file mode 100644 index 00000000000..90d985e2422 --- /dev/null +++ b/apps/studio/components/layouts/Tabs/CollapseButton.tsx @@ -0,0 +1,37 @@ +import { PanelLeftClose, PanelLeftOpen } from 'lucide-react' + +import { useAppStateSnapshot } from 'state/app-state' +import { cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui' + +export function CollapseButton({ hideTabs }: { hideTabs: boolean }) { + const { showSidebar, setShowSidebar } = useAppStateSnapshot() + + return ( + + + + + {true ? 'Collapse' : 'Expand'} sidebar + + ) +} diff --git a/apps/studio/components/layouts/Tabs/MigrateToPostgres.tsx b/apps/studio/components/layouts/Tabs/MigrateToPostgres.tsx new file mode 100644 index 00000000000..e2629ecc6bf --- /dev/null +++ b/apps/studio/components/layouts/Tabs/MigrateToPostgres.tsx @@ -0,0 +1,46 @@ +import { motion } from 'framer-motion' +import { BaggageClaim, BookOpen } from 'lucide-react' +import { Button } from 'ui' + +// not in use yet +export const MigrateToPostgres = () => ( + +
    +
    +
    +
    +
    + +
    +
    +

    Migrate your Database to Postgres

    +

    + Already have a database? Migrate any database to Supabase to access backups, RESTful + auto APIs, Authentication and more. +

    +
    +
    +
    + +
    +
    +) diff --git a/apps/studio/components/layouts/Tabs/NewTab.tsx b/apps/studio/components/layouts/Tabs/NewTab.tsx new file mode 100644 index 00000000000..25718d60ba1 --- /dev/null +++ b/apps/studio/components/layouts/Tabs/NewTab.tsx @@ -0,0 +1,188 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { partition } from 'lodash' +import { Table2 } from 'lucide-react' +import Link from 'next/link' +import { useRouter } from 'next/router' +import { toast } from 'sonner' + +import { useParams } from 'common' +import { SQL_TEMPLATES } from 'components/interfaces/SQLEditor/SQLEditor.queries' +import { createSqlSnippetSkeletonV2 } from 'components/interfaces/SQLEditor/SQLEditor.utils' +import { useSendEventMutation } from 'data/telemetry/send-event-mutation' +import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' +import { uuidv4 } from 'lib/helpers' +import { useProfile } from 'lib/profile' +import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2' +import { useTableEditorStateSnapshot } from 'state/table-editor' +import { getTabsStore } from 'state/tabs' +import { + Button, + cn, + SQL_ICON, + Tabs_Shadcn_, + TabsContent_Shadcn_, + TabsList_Shadcn_, + TabsTrigger_Shadcn_, +} from 'ui' +import { useEditorType } from '../editors/EditorsLayout.hooks' +import { useProjectContext } from '../ProjectLayout/ProjectContext' +import { ActionCard } from './ActionCard' +import { RecentItems } from './RecentItems' + +export function NewTab() { + const router = useRouter() + const { ref } = useParams() + const editor = useEditorType() + const snap = useTableEditorStateSnapshot() + const { profile } = useProfile() + const [templates] = partition(SQL_TEMPLATES, { type: 'template' }) + const [quickstarts] = partition(SQL_TEMPLATES, { type: 'quickstart' }) + const { mutate: sendEvent } = useSendEventMutation() + const snapV2 = useSqlEditorV2StateSnapshot() + const { project } = useProjectContext() + const org = useSelectedOrganization() + + const canCreateSQLSnippet = useCheckPermissions(PermissionAction.CREATE, 'user_content', { + resource: { type: 'sql', owner_id: profile?.id }, + subject: { id: profile?.id }, + }) + + const tableEditorActions = [ + { + icon: , + title: 'Create a table', + description: 'Design and create a new database table', + bgColor: 'bg-blue-500', + isBeta: false, + onClick: snap.onAddTable, + }, + ] + + const sqlEditorActions = [ + { + icon: , + title: 'New SQL Snippet', + description: 'Execute SQL queries', + bgColor: 'bg-green-500', + isBeta: false, + onClick: () => router.push(`/project/${ref}/sql/new`), + }, + ] + + const actions = editor === 'sql' ? sqlEditorActions : tableEditorActions + + const handleNewQuery = async (sql: string, name: string) => { + if (!ref) return console.error('Project ref is required') + if (!project) return console.error('Project is required') + if (!profile) return console.error('Profile is required') + + if (!canCreateSQLSnippet) { + return toast('Your queries will not be saved as you do not have sufficient permissions') + } + + try { + const snippet = createSqlSnippetSkeletonV2({ + id: uuidv4(), + name, + sql, + owner_id: profile?.id, + project_id: project?.id, + }) + snapV2.addSnippet({ projectRef: ref, snippet }) + snapV2.addNeedsSaving(snippet.id) + + const store = getTabsStore(ref) + const tabId = `sql-${snippet.id}` + store.openTabs = [...store.openTabs, tabId] + store.tabsMap[tabId] = { + id: tabId, + type: 'sql', + label: name, + metadata: { sqlId: snippet.id }, + } + store.activeTab = tabId + + router.push(`/project/${ref}/sql/${snippet.id}`) + } catch (error: any) { + toast.error(`Failed to create new query: ${error.message}`) + } + } + + return ( +
    +
    +
    + {actions.map((item, i) => ( + + ))} +
    + +
    + {editor === 'sql' && ( +
    + + + Templates + Quickstarts + + +
    + {templates.slice(0, 9).map((item, i) => ( + { + handleNewQuery(item.sql, item.title) + sendEvent({ + action: 'sql_editor_template_clicked', + properties: { templateName: item.title }, + groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' }, + }) + }} + bgColor="bg-alternative border" + key={`action-card-${i}`} + {...item} + icon={ + + } + /> + ))} +
    +
    + +
    +
    + +
    + {quickstarts.map((item, i) => ( + { + handleNewQuery(item.sql, item.title) + sendEvent({ + action: 'sql_editor_quickstart_clicked', + properties: { quickstartName: item.title }, + groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' }, + }) + }} + bgColor="bg-alternative border" + key={`action-card-${i}`} + {...item} + icon={ + + } + /> + ))} +
    +
    + +
    +
    +
    +
    + )} +
    + ) +} diff --git a/apps/studio/components/layouts/Tabs/RecentItems.tsx b/apps/studio/components/layouts/Tabs/RecentItems.tsx new file mode 100644 index 00000000000..4fc55e3c4f4 --- /dev/null +++ b/apps/studio/components/layouts/Tabs/RecentItems.tsx @@ -0,0 +1,98 @@ +import dayjs from 'dayjs' +import relativeTime from 'dayjs/plugin/relativeTime' +import { AnimatePresence, motion } from 'framer-motion' +import Link from 'next/link' +import { useSnapshot } from 'valtio' + +import { useParams } from 'common' +import { EntityTypeIcon } from 'components/ui/EntityTypeIcon' +import { ENTITY_TYPE } from 'data/entity-types/entity-type-constants' +import type { RecentItem } from 'state/recent-items' +import { getRecentItemsStore } from 'state/recent-items' +import { editorEntityTypes } from 'state/tabs' +import { useEditorType } from '../editors/EditorsLayout.hooks' + +dayjs.extend(relativeTime) + +export function RecentItems() { + const { ref } = useParams() + const store = getRecentItemsStore(ref) + const recentItemsSnap = useSnapshot(store) + const editor = useEditorType() + + if (!recentItemsSnap?.items || !ref) return null + + // Filter items based on editor type + const filteredItems = !editor + ? [...recentItemsSnap.items] + : recentItemsSnap.items.filter((item) => editorEntityTypes[editor].includes(item.type)) + + const sortedItems = filteredItems.sort((a, b) => b.timestamp - a.timestamp) + + return ( +
    +

    Recent items

    +
    + {sortedItems.length === 0 ? ( + + +
    +

    No recent items yet

    +

    + Items will appear here as you browse through your project +

    +
    +
    + ) : ( + +
    + {sortedItems.map((item: RecentItem, index: number) => ( + + +
    + +
    +
    + + {item.metadata?.schema} + {item.metadata?.schema && .} + {item.label || 'Untitled'} + +
    + + {dayjs(item.timestamp).fromNow()} + +
    + +
    + ))} +
    +
    + )} +
    +
    + ) +} diff --git a/apps/studio/components/layouts/Tabs/Tabs.utils.ts b/apps/studio/components/layouts/Tabs/Tabs.utils.ts new file mode 100644 index 00000000000..e9636ab5177 --- /dev/null +++ b/apps/studio/components/layouts/Tabs/Tabs.utils.ts @@ -0,0 +1,98 @@ +import { Entity } from 'data/entity-types/entity-types-infinite-query' +import { + getRecentItemsByType, + getRecentItemsStore, + RecentItem, + removeRecentItems, +} from 'state/recent-items' +import { createTabId, getTabsStore, removeTabs } from 'state/tabs' + +export const tableEditorTabsCleanUp = ({ + ref, + schemas, + entities, +}: { + ref: string + schemas: string[] + entities: Entity[] +}) => { + // Identity all entities by tab ID + const entitiesById: string[] = entities.map((x: Entity) => createTabId(x.type, { id: x.id })) + + // Remove any recent items that no longer exists in the database + const recentItemsStore = getRecentItemsStore(ref) + + const recentItemsFilteredToSchemas: string[] = [] + for (const schema of schemas) { + recentItemsFilteredToSchemas.push( + ...recentItemsStore?.items + ?.filter((x: RecentItem) => x.metadata?.schema === schema) + .map((x) => x.id) + ) + } + + const recentItemsToRemove = [ + ...recentItemsFilteredToSchemas.filter((entityId) => !entitiesById.includes(entityId)), + ] + + removeRecentItems(ref, recentItemsToRemove) + + // Close any tabs which might have been opened but no longer exists in the database + const tabsStore = getTabsStore(ref) + + const tabsFilteredToSchemas: string[] = [] + for (const schema of schemas) { + tabsFilteredToSchemas.push( + ...tabsStore?.openTabs?.filter((tabId) => { + const tab = tabsStore.tabsMap[tabId] + return tab.metadata?.schema === schema + }) + ) + } + + const tableEditorTabsToBeCleaned = [ + ...tabsFilteredToSchemas.filter( + (id) => !id.startsWith('sql') && !id.startsWith('schema') && !entitiesById.includes(id) + ), + ] + + // perform tabs cleanup + removeTabs(ref, tableEditorTabsToBeCleaned) +} + +export const sqlEditorTabsCleanup = ({ + ref, + snippets, +}: { + ref: string + snippets: { id: string; type: string }[] +}) => { + // these are tabs that are static content + // these canot be removed from localstorage based on this query request + const IGNORED_TAB_IDS = ['sql-templates', 'sql-quickstarts'] + + // Identify all SQL snippets / content by their tab ids + const currentContentIds = [ + ...snippets + .filter((content) => content.type === 'sql') + .map((content) => createTabId('sql', { id: content.id })), + // append ignored tab IDs + ...IGNORED_TAB_IDS, + ] + + // Remove any snippet tabs that might no longer be existing (removed outside of the dashboard session) + const tabsStore = getTabsStore(ref) + const snippetTabsToBeCleaned = tabsStore.openTabs.filter( + (id: string) => id.startsWith('sql') && !currentContentIds.includes(id) + ) + removeTabs(ref, snippetTabsToBeCleaned) + + // Remove any recent items that might no longer be existing (removed outside of the dashboard session) + const recentItems = getRecentItemsByType(ref, 'sql') + removeRecentItems( + ref, + recentItems + ? recentItems.filter((item) => !currentContentIds.includes(item.id)).map((item) => item.id) + : [] + ) +} diff --git a/apps/studio/components/layouts/editors/EditorBaseLayout.tsx b/apps/studio/components/layouts/editors/EditorBaseLayout.tsx new file mode 100644 index 00000000000..7bbb23035b0 --- /dev/null +++ b/apps/studio/components/layouts/editors/EditorBaseLayout.tsx @@ -0,0 +1,52 @@ +import { usePathname } from 'next/navigation' +import { ComponentProps, ReactNode } from 'react' + +import { useParams } from 'common' +import { + useIsSQLEditorTabsEnabled, + useIsTableEditorTabsEnabled, +} from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' +import { cn } from 'ui' +import { ProjectLayoutWithAuth } from '../ProjectLayout/ProjectLayout' +import { Tabs } from '../Tabs' +import { CollapseButton } from '../Tabs/CollapseButton' +import { useEditorType } from './EditorsLayout.hooks' + +export interface ExplorerLayoutProps extends ComponentProps { + children: ReactNode + hideTabs?: boolean + title?: string + product?: string +} + +export const EditorBaseLayout = ({ children, title, product, ...props }: ExplorerLayoutProps) => { + const { ref } = useParams() + const pathname = usePathname() + const editor = useEditorType() + + const isTableEditorTabsEnabled = useIsTableEditorTabsEnabled() + const isSQLEditorTabsEnabled = useIsSQLEditorTabsEnabled() + + const tableEditorTabsEnabled = editor === 'table' && isTableEditorTabsEnabled + const sqlEditorTabsEnabled = editor === 'sql' && isSQLEditorTabsEnabled + const hideTabs = pathname === `/project/${ref}/editor` || pathname === `/project/${ref}/sql` + + return ( + +
    + {tableEditorTabsEnabled || sqlEditorTabsEnabled ? ( +
    + {hideTabs && } + {!hideTabs && } +
    + ) : null} +
    {children}
    +
    +
    + ) +} diff --git a/apps/studio/components/layouts/editors/EditorsLayout.hooks.ts b/apps/studio/components/layouts/editors/EditorsLayout.hooks.ts new file mode 100644 index 00000000000..1495cc2b374 --- /dev/null +++ b/apps/studio/components/layouts/editors/EditorsLayout.hooks.ts @@ -0,0 +1,13 @@ +import { useParams } from 'common' +import { usePathname } from 'next/navigation' + +export function useEditorType(): 'table' | 'sql' | undefined { + const pathname = usePathname() + const { ref } = useParams() + + return pathname?.includes(`/project/${ref}/editor`) + ? 'table' + : pathname?.includes(`/project/${ref}/sql`) + ? 'sql' + : undefined +} diff --git a/apps/studio/data/entity-types/entity-types-infinite-query.ts b/apps/studio/data/entity-types/entity-types-infinite-query.ts index ab8e7cf0bcc..6afdb4f9a0f 100644 --- a/apps/studio/data/entity-types/entity-types-infinite-query.ts +++ b/apps/studio/data/entity-types/entity-types-infinite-query.ts @@ -1,5 +1,6 @@ import { QueryClient, useInfiniteQuery, UseInfiniteQueryOptions } from '@tanstack/react-query' import { executeSql, ExecuteSqlVariables } from 'data/sql/execute-sql-query' +import { ResponseError } from 'types' import { ENTITY_TYPE } from './entity-type-constants' import { entityTypeKeys } from './keys' @@ -114,7 +115,7 @@ export async function getEntityTypes( } export type EntityTypesData = Awaited> -export type EntityTypesError = unknown +export type EntityTypesError = ResponseError export const useEntityTypesQuery = ( { @@ -130,8 +131,8 @@ export const useEntityTypesQuery = ( enabled = true, ...options }: UseInfiniteQueryOptions = {} -) => - useInfiniteQuery( +) => { + return useInfiniteQuery( entityTypeKeys.list(projectRef, { schemas, search, sort, limit, filterTypes }), ({ signal, pageParam }) => getEntityTypes( @@ -163,6 +164,7 @@ export const useEntityTypesQuery = ( ...options, } ) +} export function prefetchEntityTypes( client: QueryClient, diff --git a/apps/studio/data/tables/tables-query.ts b/apps/studio/data/tables/tables-query.ts index b6187f0a76d..76463bc224e 100644 --- a/apps/studio/data/tables/tables-query.ts +++ b/apps/studio/data/tables/tables-query.ts @@ -59,6 +59,7 @@ export async function getTables( if (Array.isArray(data) && sortByProperty) { return sortBy(data, (t) => t[sortByProperty]) as PostgresTable[] } + return data as Omit[] } diff --git a/apps/studio/lib/constants/index.ts b/apps/studio/lib/constants/index.ts index 1e9fe8b0845..d84cc68ed39 100644 --- a/apps/studio/lib/constants/index.ts +++ b/apps/studio/lib/constants/index.ts @@ -50,6 +50,9 @@ export const LOCAL_STORAGE_KEYS = { UI_PREVIEW_INLINE_EDITOR: 'supabase-ui-preview-inline-editor', UI_ONBOARDING_NEW_PAGE_SHOWN: 'supabase-ui-onboarding-new-page-shown', + UI_TABLE_EDITOR_TABS: 'supabase-ui-table-editor-tabs', + UI_SQL_EDITOR_TABS: 'supabase-ui-sql-editor-tabs', + SQL_SCRATCH_PAD_BANNER_ACKNOWLEDGED: 'supabase-sql-scratch-pad-banner-acknowledged', DASHBOARD_HISTORY: (ref: string) => `dashboard-history-${ref}`, diff --git a/apps/studio/next.config.js b/apps/studio/next.config.js index 55fc4c9417e..c2e729d23fa 100644 --- a/apps/studio/next.config.js +++ b/apps/studio/next.config.js @@ -380,11 +380,6 @@ const nextConfig = { destination: '/org/_/billing', permanent: true, }, - { - source: '/project/:ref/sql', - destination: '/project/:ref/sql/new', - permanent: true, - }, { permanent: true, source: '/project/:ref/reports/linter', diff --git a/apps/studio/package.json b/apps/studio/package.json index 801cd1fcbc4..23c2c6aecd4 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -25,6 +25,9 @@ "@ai-sdk/openai": "^0.0.72", "@dagrejs/dagre": "^1.0.4", "@deno/eszip": "0.83.0", + "@dnd-kit/core": "^6.1.0", + "@dnd-kit/sortable": "^8.0.0", + "@dnd-kit/utilities": "^3.2.2", "@electric-sql/pglite": "^0.2.15", "@graphiql/react": "^0.19.4", "@graphiql/toolkit": "^0.9.1", diff --git a/apps/studio/pages/api/platform/projects/[ref]/content/count.ts b/apps/studio/pages/api/platform/projects/[ref]/content/count.ts index 10c30fe73e5..875d4c1f8ab 100644 --- a/apps/studio/pages/api/platform/projects/[ref]/content/count.ts +++ b/apps/studio/pages/api/platform/projects/[ref]/content/count.ts @@ -20,5 +20,5 @@ type ResponseData = paths['/platform/projects/{ref}/content/count']['get']['responses']['200']['content']['application/json'] const handleGetAll = async (req: NextApiRequest, res: NextApiResponse) => { - return res.status(200).json({ shared: 1, favorites: 1, private: 1 }) + return res.status(200).json({ shared: 0, favorites: 0, private: 1 }) } diff --git a/apps/studio/pages/api/platform/projects/[ref]/content/index.ts b/apps/studio/pages/api/platform/projects/[ref]/content/index.ts index b3325c69333..9bc53912644 100644 --- a/apps/studio/pages/api/platform/projects/[ref]/content/index.ts +++ b/apps/studio/pages/api/platform/projects/[ref]/content/index.ts @@ -28,6 +28,11 @@ type GetResponseData = const handleGetAll = async (req: NextApiRequest, res: NextApiResponse) => { // Platform specific endpoint + const { favorite, visibility } = req.query + if (favorite || visibility === 'project') { + return res.status(200).json({ data: [] }) + } + const snippets = [ { id: '1', diff --git a/apps/studio/pages/project/[ref]/database/column-privileges.tsx b/apps/studio/pages/project/[ref]/database/column-privileges.tsx index 4111c0b4076..0409e6a391a 100644 --- a/apps/studio/pages/project/[ref]/database/column-privileges.tsx +++ b/apps/studio/pages/project/[ref]/database/column-privileges.tsx @@ -4,7 +4,7 @@ import Link from 'next/link' import { useCallback, useMemo, useState } from 'react' import { toast } from 'sonner' -import { useFeaturePreviewContext } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' +import { useIsColumnLevelPrivilegesEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { getDefaultColumnCheckedStates, getDefaultTableCheckedStates, @@ -15,6 +15,7 @@ import PrivilegesHead from 'components/interfaces/Database/Privileges/Privileges import PrivilegesTable from 'components/interfaces/Database/Privileges/PrivilegesTable' import ProtectedSchemaWarning from 'components/interfaces/Database/ProtectedSchemaWarning' import DatabaseLayout from 'components/layouts/DatabaseLayout/DatabaseLayout' +import DefaultLayout from 'components/layouts/DefaultLayout' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold' import AlertError from 'components/ui/AlertError' @@ -31,7 +32,6 @@ import { PROTECTED_SCHEMAS } from 'lib/constants/schemas' import { useAppStateSnapshot } from 'state/app-state' import type { NextPageWithLayout } from 'types' import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, Button } from 'ui' -import DefaultLayout from 'components/layouts/DefaultLayout' const EDITABLE_ROLES = ['authenticated', 'anon', 'service_role'] @@ -39,10 +39,7 @@ const PrivilegesPage: NextPageWithLayout = () => { const { ref, table: paramTable } = useParams() const { project } = useProjectContext() const snap = useAppStateSnapshot() - - const featurePreviewContext = useFeaturePreviewContext() - const { flags } = featurePreviewContext - const isEnabled = flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_CLS] + const isEnabled = useIsColumnLevelPrivilegesEnabled() const { selectedSchema, setSelectedSchema } = useQuerySchemaState() const [selectedTable, setSelectedTable] = useState(paramTable) diff --git a/apps/studio/pages/project/[ref]/editor/[id].tsx b/apps/studio/pages/project/[ref]/editor/[id].tsx index 36438f75894..ea6977367ca 100644 --- a/apps/studio/pages/project/[ref]/editor/[id].tsx +++ b/apps/studio/pages/project/[ref]/editor/[id].tsx @@ -1,19 +1,24 @@ import { useRouter } from 'next/router' -import { useCallback } from 'react' +import { useCallback, useEffect } from 'react' -import { useParams } from 'common/hooks' +import { useParams } from 'common' +import { useIsTableEditorTabsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { TableGridEditor } from 'components/interfaces/TableGridEditor' import DeleteConfirmationDialogs from 'components/interfaces/TableGridEditor/DeleteConfirmationDialogs' import DefaultLayout from 'components/layouts/DefaultLayout' +import { EditorBaseLayout } from 'components/layouts/editors/EditorBaseLayout' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import TableEditorLayout from 'components/layouts/TableEditorLayout/TableEditorLayout' +import TableEditorMenu from 'components/layouts/TableEditorLayout/TableEditorMenu' import { useTableEditorQuery } from 'data/table-editor/table-editor-query' import { TablesData } from 'data/tables/tables-query' +import { addTab, createTabId, getTabsStore } from 'state/tabs' import type { NextPageWithLayout } from 'types' const TableEditorPage: NextPageWithLayout = () => { const { id: _id, ref: projectRef } = useParams() const id = _id ? Number(_id) : undefined + const store = getTabsStore(projectRef) const { project } = useProjectContext() const { data: selectedTable, isLoading } = useTableEditorQuery({ @@ -35,6 +40,36 @@ const TableEditorPage: NextPageWithLayout = () => { [router, projectRef] ) + /** + * Effect: Creates or updates tab when table is loaded + * Runs when: + * - selectedTable changes (when a new table is loaded) + * - id changes (when URL parameter changes) + */ + const isTableEditorTabsEnabled = useIsTableEditorTabsEnabled() + + useEffect(() => { + // tabs preview flag logic + if (isTableEditorTabsEnabled && selectedTable && projectRef) { + const tabId = createTabId(selectedTable.entity_type, { id: selectedTable.id }) + if (!store.tabsMap[tabId]) { + addTab(projectRef, { + id: tabId, + type: selectedTable.entity_type, + label: selectedTable.name, + metadata: { + schema: selectedTable.schema, + name: selectedTable.name, + tableId: id, + }, + }) + } else { + // If tab already exists, just make it active + store.activeTab = tabId + } + } + }, [selectedTable, id, projectRef, isTableEditorTabsEnabled]) + return ( <> @@ -48,7 +83,9 @@ const TableEditorPage: NextPageWithLayout = () => { TableEditorPage.getLayout = (page) => ( - {page} + } product="Table Editor"> + {page} + ) diff --git a/apps/studio/pages/project/[ref]/editor/index.tsx b/apps/studio/pages/project/[ref]/editor/index.tsx index ca9392c8485..2f3bebfa86f 100644 --- a/apps/studio/pages/project/[ref]/editor/index.tsx +++ b/apps/studio/pages/project/[ref]/editor/index.tsx @@ -1,24 +1,48 @@ -import { useRouter } from 'next/router' - import { useParams } from 'common' - +import { useIsTableEditorTabsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import EmptyState from 'components/interfaces/TableGridEditor/EmptyState' import SidePanelEditor from 'components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor' import DefaultLayout from 'components/layouts/DefaultLayout' +import { EditorBaseLayout } from 'components/layouts/editors/EditorBaseLayout' import TableEditorLayout from 'components/layouts/TableEditorLayout/TableEditorLayout' +import TableEditorMenu from 'components/layouts/TableEditorLayout/TableEditorMenu' +import { NewTab } from 'components/layouts/Tabs/NewTab' +import { useRouter } from 'next/router' +import { useEffect } from 'react' +import { useAppStateSnapshot } from 'state/app-state' +import { editorEntityTypes, getTabsStore } from 'state/tabs' import type { NextPageWithLayout } from 'types' const TableEditorPage: NextPageWithLayout = () => { - const { ref: projectRef } = useParams() const router = useRouter() + const { ref: projectRef } = useParams() + const tabStore = getTabsStore(projectRef) + const appSnap = useAppStateSnapshot() + const isTableEditorTabsEnabled = useIsTableEditorTabsEnabled() const onTableCreated = (table: { id: number }) => { router.push(`/project/${projectRef}/editor/${table.id}`) } + useEffect(() => { + if (isTableEditorTabsEnabled) { + const lastOpenedTab = appSnap.dashboardHistory.editor + const lastTabId = tabStore.openTabs.find((id) => + editorEntityTypes.table.includes(tabStore.tabsMap[id]?.type) + ) + if (lastOpenedTab !== undefined) { + router.push(`/project/${projectRef}/editor/${appSnap.dashboardHistory.editor}`) + } else if (lastTabId) { + // Handle redirect to last opened table tab, or last table tab + const lastTab = tabStore.tabsMap[lastTabId] + if (lastTab) router.push(`/project/${projectRef}/editor/${lastTab.metadata?.tableId}`) + } + } + }, [isTableEditorTabsEnabled]) + return ( <> - + {isTableEditorTabsEnabled ? : } ) @@ -26,7 +50,9 @@ const TableEditorPage: NextPageWithLayout = () => { TableEditorPage.getLayout = (page) => ( - {page} + } product="Table Editor"> + {page} + ) diff --git a/apps/studio/pages/project/[ref]/editor/new.tsx b/apps/studio/pages/project/[ref]/editor/new.tsx new file mode 100644 index 00000000000..3a2e12a9e3b --- /dev/null +++ b/apps/studio/pages/project/[ref]/editor/new.tsx @@ -0,0 +1,36 @@ +import { useRouter } from 'next/router' + +import { useParams } from 'common' +import { SidePanelEditor } from 'components/interfaces/TableGridEditor' +import DefaultLayout from 'components/layouts/DefaultLayout' +import { EditorBaseLayout } from 'components/layouts/editors/EditorBaseLayout' +import TableEditorLayout from 'components/layouts/TableEditorLayout/TableEditorLayout' +import TableEditorMenu from 'components/layouts/TableEditorLayout/TableEditorMenu' +import { NewTab } from 'components/layouts/Tabs/NewTab' +import type { NextPageWithLayout } from 'types' + +const EditorNewPage: NextPageWithLayout = () => { + const router = useRouter() + const { ref: projectRef } = useParams() + + const onTableCreated = (table: { id: number }) => { + router.push(`/project/${projectRef}/editor/${table.id}`) + } + + return ( + <> + + + + ) +} + +EditorNewPage.getLayout = (page) => ( + + } product="Table Editor"> + {page} + + +) + +export default EditorNewPage diff --git a/apps/studio/pages/project/[ref]/sql/[id].tsx b/apps/studio/pages/project/[ref]/sql/[id].tsx index 38f310f2078..8b37d86603f 100644 --- a/apps/studio/pages/project/[ref]/sql/[id].tsx +++ b/apps/studio/pages/project/[ref]/sql/[id].tsx @@ -2,13 +2,17 @@ import { useRouter } from 'next/router' import { useEffect } from 'react' import { useParams } from 'common/hooks/useParams' +import { useIsSQLEditorTabsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { SQLEditor } from 'components/interfaces/SQLEditor/SQLEditor' +import DefaultLayout from 'components/layouts/DefaultLayout' +import { EditorBaseLayout } from 'components/layouts/editors/EditorBaseLayout' import SQLEditorLayout from 'components/layouts/SQLEditorLayout/SQLEditorLayout' +import { SQLEditorMenu } from 'components/layouts/SQLEditorLayout/SQLEditorMenu' import { useContentIdQuery } from 'data/content/content-id-query' import { useAppStateSnapshot } from 'state/app-state' import { SnippetWithContent, useSnippets, useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2' +import { addTab, createTabId } from 'state/tabs' import type { NextPageWithLayout } from 'types' -import DefaultLayout from 'components/layouts/DefaultLayout' const SqlEditor: NextPageWithLayout = () => { const router = useRouter() @@ -51,16 +55,36 @@ const SqlEditor: NextPageWithLayout = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [id, allSnippets, content]) - return ( -
    - -
    - ) + const isSqlEditorTabsEnabled = useIsSQLEditorTabsEnabled() + + // Watch for route changes + useEffect(() => { + if (isSqlEditorTabsEnabled) { + if (!router.isReady || !id || id === 'new') return + + const tabId = createTabId('sql', { id }) + const snippet = allSnippets.find((x) => x.id === id) + + addTab(ref, { + id: tabId, + type: 'sql', + label: snippet?.name || 'Untitled Query', + metadata: { + sqlId: id, + name: snippet?.name, + }, + }) + } + }, [router.isReady, id, isSqlEditorTabsEnabled]) + + return } SqlEditor.getLayout = (page) => ( - {page} + } product="SQL Editor"> + {page} + ) diff --git a/apps/studio/pages/project/[ref]/sql/index.tsx b/apps/studio/pages/project/[ref]/sql/index.tsx new file mode 100644 index 00000000000..44e2019cf0a --- /dev/null +++ b/apps/studio/pages/project/[ref]/sql/index.tsx @@ -0,0 +1,49 @@ +import { useParams } from 'common' +import { useIsSQLEditorTabsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' +import DefaultLayout from 'components/layouts/DefaultLayout' +import { EditorBaseLayout } from 'components/layouts/editors/EditorBaseLayout' +import SQLEditorLayout from 'components/layouts/SQLEditorLayout/SQLEditorLayout' +import { SQLEditorMenu } from 'components/layouts/SQLEditorLayout/SQLEditorMenu' +import { NewTab } from 'components/layouts/Tabs/NewTab' +import { useRouter } from 'next/router' +import { useEffect } from 'react' +import { useAppStateSnapshot } from 'state/app-state' +import { getTabsStore } from 'state/tabs' +import type { NextPageWithLayout } from 'types' + +const TableEditorPage: NextPageWithLayout = () => { + const router = useRouter() + const { ref: projectRef } = useParams() + const store = getTabsStore(projectRef) + const appSnap = useAppStateSnapshot() + const isSqlEditorTabsEnabled = useIsSQLEditorTabsEnabled() + + useEffect(() => { + if (!isSqlEditorTabsEnabled) { + // Redirect to /new if not using tabs + router.push(`/project/${projectRef}/sql/new`) + } else { + // Handle redirect to last opened snippet tab, or last snippet tab + const lastOpenedTab = appSnap.dashboardHistory.sql + const lastTabId = store.openTabs.find((id) => store.tabsMap[id]?.type === 'sql') + if (lastOpenedTab !== undefined) { + router.push(`/project/${projectRef}/sql/${appSnap.dashboardHistory.sql}`) + } else if (lastTabId) { + const lastTab = store.tabsMap[lastTabId] + if (lastTab) router.push(`/project/${projectRef}/sql/${lastTab.id.replace('sql-', '')}`) + } + } + }, [isSqlEditorTabsEnabled]) + + return isSqlEditorTabsEnabled ? : null +} + +TableEditorPage.getLayout = (page) => ( + + } product="SQL Editor"> + {page} + + +) + +export default TableEditorPage diff --git a/apps/studio/pages/project/[ref]/sql/quickstarts.tsx b/apps/studio/pages/project/[ref]/sql/quickstarts.tsx index 7d53fea7325..71e9e91bf1d 100644 --- a/apps/studio/pages/project/[ref]/sql/quickstarts.tsx +++ b/apps/studio/pages/project/[ref]/sql/quickstarts.tsx @@ -1,16 +1,49 @@ +import { useParams } from 'next/navigation' +import { useRouter } from 'next/router' +import { useEffect } from 'react' + +import { useIsSQLEditorTabsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import SQLQuickstarts from 'components/interfaces/SQLEditor/SQLTemplates/SQLQuickstarts' import DefaultLayout from 'components/layouts/DefaultLayout' +import { EditorBaseLayout } from 'components/layouts/editors/EditorBaseLayout' import SQLEditorLayout from 'components/layouts/SQLEditorLayout/SQLEditorLayout' +import { SQLEditorMenu } from 'components/layouts/SQLEditorLayout/SQLEditorMenu' +import { addTab, createTabId } from 'state/tabs' import type { NextPageWithLayout } from 'types' -const SqlEditorWelcome: NextPageWithLayout = () => { +const SqlQuickstarts: NextPageWithLayout = () => { + const router = useRouter() + const { ref } = useParams<{ ref: string }>() + + const isSqlEditorTabsEnabled = useIsSQLEditorTabsEnabled() + + useEffect(() => { + if (isSqlEditorTabsEnabled) { + if (!router.isReady) return + + const tabId = createTabId('sql', { id: 'quickstarts' }) + + addTab(ref, { + id: tabId, + type: 'sql', + label: 'Quickstarts', + metadata: { + sqlId: 'quickstarts', + name: 'quickstarts', + }, + }) + } + }, [router.isReady, isSqlEditorTabsEnabled, ref]) + return } -SqlEditorWelcome.getLayout = (page) => ( +SqlQuickstarts.getLayout = (page) => ( - {page} + } product="SQL Editor"> + {page} + ) -export default SqlEditorWelcome +export default SqlQuickstarts diff --git a/apps/studio/pages/project/[ref]/sql/templates.tsx b/apps/studio/pages/project/[ref]/sql/templates.tsx index de06e63259a..e481286527e 100644 --- a/apps/studio/pages/project/[ref]/sql/templates.tsx +++ b/apps/studio/pages/project/[ref]/sql/templates.tsx @@ -1,16 +1,49 @@ +import { useParams } from 'next/navigation' +import { useRouter } from 'next/router' +import { useEffect } from 'react' + +import { useIsSQLEditorTabsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import SQLTemplates from 'components/interfaces/SQLEditor/SQLTemplates/SQLTemplates' import DefaultLayout from 'components/layouts/DefaultLayout' +import { EditorBaseLayout } from 'components/layouts/editors/EditorBaseLayout' import SQLEditorLayout from 'components/layouts/SQLEditorLayout/SQLEditorLayout' +import { SQLEditorMenu } from 'components/layouts/SQLEditorLayout/SQLEditorMenu' +import { addTab, createTabId } from 'state/tabs' import type { NextPageWithLayout } from 'types' -const SqlEditorWelcome: NextPageWithLayout = () => { +const SqlTemplates: NextPageWithLayout = () => { + const router = useRouter() + const { ref } = useParams<{ ref: string }>() + + const isSqlEditorTabsEnabled = useIsSQLEditorTabsEnabled() + + useEffect(() => { + if (isSqlEditorTabsEnabled) { + if (!router.isReady) return + + const tabId = createTabId('sql', { id: 'templates' }) + + addTab(ref, { + id: tabId, + type: 'sql', + label: 'Templates', + metadata: { + sqlId: 'templates', + name: 'templates', + }, + }) + } + }, [router.isReady, isSqlEditorTabsEnabled, ref]) + return } -SqlEditorWelcome.getLayout = (page) => ( +SqlTemplates.getLayout = (page) => ( - {page} + } product="SQL Editor"> + {page} + ) -export default SqlEditorWelcome +export default SqlTemplates diff --git a/apps/studio/public/img/previews/api-docs-preview.png b/apps/studio/public/img/previews/api-docs-preview.png index 64a9ce7a06c..d7c715a6733 100644 Binary files a/apps/studio/public/img/previews/api-docs-preview.png and b/apps/studio/public/img/previews/api-docs-preview.png differ diff --git a/apps/studio/public/img/previews/assistant-v2.png b/apps/studio/public/img/previews/assistant-v2.png index 02d697fd934..f30a43eeb30 100644 Binary files a/apps/studio/public/img/previews/assistant-v2.png and b/apps/studio/public/img/previews/assistant-v2.png differ diff --git a/apps/studio/public/img/previews/cls-preview.png b/apps/studio/public/img/previews/cls-preview.png index 85c3d2c8196..daacc2793bc 100644 Binary files a/apps/studio/public/img/previews/cls-preview.png and b/apps/studio/public/img/previews/cls-preview.png differ diff --git a/apps/studio/public/img/previews/rls-ai-assistant-preview.png b/apps/studio/public/img/previews/rls-ai-assistant-preview.png index de1875f1fa7..4c646ff445b 100644 Binary files a/apps/studio/public/img/previews/rls-ai-assistant-preview.png and b/apps/studio/public/img/previews/rls-ai-assistant-preview.png differ diff --git a/apps/studio/public/img/previews/tabs-editor.png b/apps/studio/public/img/previews/tabs-editor.png new file mode 100644 index 00000000000..e040742c864 Binary files /dev/null and b/apps/studio/public/img/previews/tabs-editor.png differ diff --git a/apps/studio/public/img/previews/tabs-sql.png b/apps/studio/public/img/previews/tabs-sql.png new file mode 100644 index 00000000000..c20bf073e14 Binary files /dev/null and b/apps/studio/public/img/previews/tabs-sql.png differ diff --git a/apps/studio/state/app-state.ts b/apps/studio/state/app-state.ts index d3e1bdba865..21ff3556be6 100644 --- a/apps/studio/state/app-state.ts +++ b/apps/studio/state/app-state.ts @@ -1,9 +1,9 @@ import type { Message as MessageType } from 'ai/react' import { LOCAL_STORAGE_KEYS as COMMON_LOCAL_STORAGE_KEYS } from 'common' -import { LOCAL_STORAGE_KEYS } from 'lib/constants' -import { SupportedAssistantEntities } from 'components/ui/AIAssistantPanel/AIAssistant.types' -import { proxy, snapshot, subscribe, useSnapshot } from 'valtio' import { SQL_TEMPLATES } from 'components/interfaces/SQLEditor/SQLEditor.queries' +import { SupportedAssistantEntities } from 'components/ui/AIAssistantPanel/AIAssistant.types' +import { LOCAL_STORAGE_KEYS } from 'lib/constants' +import { proxy, snapshot, subscribe, useSnapshot } from 'valtio' export type CommonDatabaseEntity = { id: number @@ -100,6 +100,7 @@ const getInitialState = () => { showGenerateSqlModal: false, ongoingQueriesPanelOpen: false, mobileMenuOpen: false, + showSidebar: true, } } @@ -149,13 +150,14 @@ const getInitialState = () => { showGenerateSqlModal: false, ongoingQueriesPanelOpen: false, mobileMenuOpen: false, + showSidebar: true, } } export const appState = proxy({ ...getInitialState(), - setDashboardHistory: (ref: string, key: 'sql' | 'editor', id: string) => { + setDashboardHistory: (ref: string, key: 'sql' | 'editor', id: string | undefined) => { if (appState.dashboardHistory[key] !== id) { appState.dashboardHistory[key] = id localStorage.setItem( @@ -211,6 +213,11 @@ export const appState = proxy({ appState.showGenerateSqlModal = value }, + showSidebar: true, + setShowSidebar: (value: boolean) => { + appState.showSidebar = value + }, + resetAiAssistantPanel: () => { appState.aiAssistantPanel = { ...INITIAL_AI_ASSISTANT, @@ -232,6 +239,11 @@ export const appState = proxy({ } }, + showOngoingQueriesPanelOpen: false, + setOnGoingQueriesPanelOpen: (value: boolean) => { + appState.ongoingQueriesPanelOpen = value + }, + saveLatestMessage: (message: any) => { appState.aiAssistantPanel = { ...appState.aiAssistantPanel, @@ -239,11 +251,6 @@ export const appState = proxy({ } }, - showOngoingQueriesPanelOpen: false, - setOnGoingQueriesPanelOpen: (value: boolean) => { - appState.ongoingQueriesPanelOpen = value - }, - setEditorPanel: (value: Partial) => { // Close AI Assistant panel if editor panel is being opened if (value.open && appState.aiAssistantPanel.open) { diff --git a/apps/studio/state/recent-items.ts b/apps/studio/state/recent-items.ts new file mode 100644 index 00000000000..a6272e3d3b4 --- /dev/null +++ b/apps/studio/state/recent-items.ts @@ -0,0 +1,160 @@ +import { partition } from 'lodash' +import { proxy, subscribe } from 'valtio' +import { editorEntityTypes, Tab, TabType } from './tabs' + +/** + * [TODO REFACTOR] Joshen: JFYI this differs from the way that we usually write our stores + * where we have contexts and such - ideally we need to refactor this for consistency + * and the main benefit from our usual way of writing stores is that we do not need to pass + * in the project ref to all the functions, as the stores are written to be scoped to the project + * + * So do not use this as reference for writing new valtio stores - refer to database-selector instead + */ + +// Define the key for storing recent items in localStorage +const RECENT_ITEMS_KEY = 'supabase_recent_items' +// Define the maximum number of recent items to store +const MAX_RECENT_ITEMS = 8 + +const getStorageKey = (ref: string) => `${RECENT_ITEMS_KEY}_${ref}` + +// Define the structure of a recent item +export interface RecentItem { + id: string // Unique identifier for the item + type: TabType // Type of the tab (e.g., sql, table) + label: string // Label for the item + timestamp: number // Timestamp of when the item was added + metadata?: { + // Optional metadata associated with the item + schema?: string // Schema name (if applicable) + name?: string // Name of the item (if applicable) + tableId?: number // Table ID (if applicable) + sqlId?: string // SQL ID (if applicable) + } +} + +// Define the structure of the recent items state +interface RecentItems { + items: RecentItem[] // Each project just has an array of items +} + +interface RecentItemsStore { + [key: string]: RecentItems // Removed | undefined since we always initialize it +} + +const defaultState: RecentItems = { + items: [], +} + +export const recentItemsStore = proxy({}) + +export const getRecentItemsStore = (ref: string | undefined): RecentItems => { + if (!ref) return proxy(defaultState) + if (!recentItemsStore[ref]) { + const stored = localStorage.getItem(getStorageKey(ref)) + console.debug('[Recent Items] Loading stored items:', stored) + + try { + const parsed = stored ? JSON.parse(stored) : defaultState + // Validate the shape of the data + if (!parsed.items || !Array.isArray(parsed.items)) { + console.warn('[Recent Items] Invalid stored data, using default') + recentItemsStore[ref] = proxy({ ...defaultState }) + } else { + recentItemsStore[ref] = proxy(parsed) + } + } catch (error) { + console.error('[Recent Items] Failed to parse stored items:', error) + recentItemsStore[ref] = proxy({ ...defaultState }) + } + } + + return recentItemsStore[ref] +} + +// Subscribe to changes for each ref and save to localStorage +if (typeof window !== 'undefined') { + subscribe(recentItemsStore, () => { + Object.entries(recentItemsStore).forEach(([ref, state]) => { + try { + localStorage.setItem(getStorageKey(ref), JSON.stringify(state)) + } catch (error) { + console.error('Failed to save recent items to localStorage:', error) + } + }) + }) +} + +// Function to add a recent item +export const addRecentItem = (ref: string | undefined, tab: Tab) => { + if (!ref) return + const store = getRecentItemsStore(ref) + // Check if an item with the same ID already exists + const existingItem = store.items.find((item) => item.id === tab.id) + + if (existingItem) { + // If it exists, update its timestamp + existingItem.timestamp = Date.now() + return // Exit the function + } + + // If it doesn't exist, create and add a new item + const recentItem: RecentItem = { + id: tab.id, // Set the ID + type: tab.type, // Set the type + label: tab.label || 'Untitled', // Set the label or default to 'Untitled' + timestamp: Date.now(), // Set the current timestamp + metadata: tab.metadata, // Set the metadata + } + + // Add the new recent item to the beginning of the list + store.items.unshift(recentItem) + + // Ensure that there's only up to max of MAX_RECENT_ITEMS items per tab type + const [itemsOfSameType, itemsOfDifferentType] = partition(store.items, (item) => { + if (editorEntityTypes.table.includes(item.type)) return item + }) + store.items = [...itemsOfSameType.slice(0, MAX_RECENT_ITEMS), ...itemsOfDifferentType] +} + +// Function to clear all recent items +export const clearRecentItems = (ref: string | undefined) => { + if (!ref) return + const store = getRecentItemsStore(ref) + store.items = [] // Set items to an empty array +} + +// Function to remove a specific recent item by its ID +export const removeRecentItem = (ref: string | undefined, itemId: string) => { + if (!ref) return + const store = getRecentItemsStore(ref) + store.items = store.items.filter((item) => item.id !== itemId) +} + +// Function to remove multiple recent items by their IDs +export const removeRecentItems = (ref: string | undefined, itemIds: string[]) => { + if (!ref) return + const store = getRecentItemsStore(ref) + store.items = store.items.filter((item) => !itemIds.includes(item.id)) +} + +// Function to remove recent items by their type +export const removeRecentItemsByType = (ref: string | undefined, type: TabType) => { + if (!ref) return + const store = getRecentItemsStore(ref) + store.items = store.items.filter((item) => item.type !== type) +} + +// Function to get all recent items +export const getRecentItems = (ref: string | undefined) => { + if (!ref) return + const store = getRecentItemsStore(ref) + return store.items +} + +// Function to get recent items by their type +export const getRecentItemsByType = (ref: string | undefined, type: TabType) => { + if (!ref) return + const store = getRecentItemsStore(ref) + return store.items.filter((item) => item.type === type) +} diff --git a/apps/studio/state/tabs.ts b/apps/studio/state/tabs.ts new file mode 100644 index 00000000000..7b3b50755f0 --- /dev/null +++ b/apps/studio/state/tabs.ts @@ -0,0 +1,392 @@ +import { ENTITY_TYPE } from 'data/entity-types/entity-type-constants' +import { NextRouter } from 'next/router' +import { ReactNode } from 'react' +import { proxy, subscribe } from 'valtio' +import { addRecentItem } from './recent-items' + +/** + * [TODO REFACTOR] Joshen: JFYI this differs from the way that we usually write our stores + * where we have contexts and such - ideally we need to refactor this for consistency + * and the main benefit from our usual way of writing stores is that we do not need to pass + * in the project ref to all the functions, as the stores are written to be scoped to the project + * + * So do not use this as reference for writing new valtio stores - refer to database-selector instead + */ + +// Define the type of tabs available in the application +export type TabType = ENTITY_TYPE | 'sql' + +export interface Tab { + id: string // Unique identifier for the tab + type: TabType // Type of the tab, which can be an entity type or specific string values + label?: string // Optional label for the tab, displayed to the user + icon?: ReactNode // Optional icon associated with the tab + metadata?: { + // Optional metadata related to the tab + schema?: string // Optional schema name associated with the tab + name?: string // Optional name of the entity represented by the tab + tableId?: number // Optional ID of the table associated with the tab + sqlId?: string // Optional ID of the SQL query associated with the tab + } + isPreview?: boolean // Optional flag indicating if the tab is in preview mode + createdAt?: Date // Optional timestamp for when the tab was created + updatedAt?: Date // Optional timestamp for when the tab was last updated +} + +// Interface representing the state of tabs +interface TabsState { + activeTab: string | null + openTabs: string[] + tabsMap: { [key: string]: Tab } + previewTabId?: string +} + +// Map of tab states for different references +interface TabsStateMap { + [ref: string]: TabsState +} + +const STORAGE_KEY = 'supabase_studio_tabs' +const getStorageKey = (ref: string) => `${STORAGE_KEY}_${ref}` + +const defaultState: TabsState = { + openTabs: [], + activeTab: null, + tabsMap: {}, +} + +// Store now holds states for multiple refs +export const tabsStore = proxy({}) + +// Helper to get/create state for a specific ref +export const getTabsStore = (ref: string | undefined): TabsState => { + if (!ref) return proxy(defaultState) + if (!tabsStore[ref]) { + const stored = localStorage.getItem(getStorageKey(ref)) + console.debug('[Tabs] Loading stored tabs:', stored) + try { + const parsed = stored ? JSON.parse(stored) : defaultState + if ( + !parsed.openTabs || + !Array.isArray(parsed.openTabs) || + !parsed.tabsMap || + typeof parsed.tabsMap !== 'object' + ) { + console.warn('[Tabs] Invalid stored data, using default') + tabsStore[ref] = proxy({ ...defaultState }) + } else { + tabsStore[ref] = proxy(parsed) + } + } catch (error) { + console.error('[Tabs] Failed to parse stored tabs:', error) + tabsStore[ref] = proxy({ ...defaultState }) + } + } + + return tabsStore[ref] +} + +// Subscribe to changes for each ref and save to localStorage +if (typeof window !== 'undefined') { + subscribe(tabsStore, () => { + Object.entries(tabsStore).forEach(([ref, state]) => { + try { + localStorage.setItem(getStorageKey(ref), JSON.stringify(state)) + } catch (error) { + console.error('Failed to save tabs to localStorage:', error) + } + }) + }) +} + +// Function to add a new tab to the store +export const addTab = (ref: string | undefined, tab: Tab) => { + if (!ref) return + const store = getTabsStore(ref) + + // If tab exists and is active, don't do anything + if (store.tabsMap[tab.id] && store.activeTab === tab.id) { + return + } + + // If tab exists but isn't active, just make it active + if (store.tabsMap[tab.id]) { + store.activeTab = tab.id + if (!tab.isPreview) addRecentItem(ref, tab) + return + } + + // If this tab should be permanent, add it normally + if (tab.isPreview === false) { + store.openTabs = [...store.openTabs, tab.id] + store.tabsMap[tab.id] = tab + store.activeTab = tab.id + // Add to recent items when creating permanent tab + addRecentItem(ref, tab) + return + } + + // Remove any existing preview tab + if (store.previewTabId) { + store.openTabs = store.openTabs.filter((id) => id !== store.previewTabId) + delete store.tabsMap[store.previewTabId] + } + + // Add new preview tab + store.tabsMap[tab.id] = { ...tab, isPreview: true } + store.openTabs = [...store.openTabs, tab.id] + store.previewTabId = tab.id + store.activeTab = tab.id +} + +// Function to remove a tab from the store +// this is used for removing tabs from the localstorage state +// for handling a manual tab removal with a close action, use handleTabClose() +export const removeTab = (ref: string | undefined, id: string) => { + console.log('removeTab') + const store = getTabsStore(ref) + const idx = store.openTabs.indexOf(id) + store.openTabs = store.openTabs.filter((tabId) => tabId !== id) + delete store.tabsMap[id] + + // Update active tab if the removed tab was active + if (id === store.activeTab) { + store.activeTab = store.openTabs[idx - 1] || store.openTabs[idx + 1] || null + } +} + +// Function to remove multiple tabs from the store +// this is used for removing tabs from the localstorage state +// for handling a manual tab removal with a close action, use handleTabClose() +export const removeTabs = (ref: string | undefined, ids: string[]) => { + if (!ref) return + if (!ids.length) return + + ids.forEach((id) => removeTab(ref, id)) +} + +export const renameTab = (ref: string, id: string, name: string) => { + const store = getTabsStore(ref) + if (!!store.tabsMap[id]) { + store.tabsMap[id].label = name + } +} + +// Function to reorder tabs in the store +export const reorderTabs = (ref: string | undefined, oldIndex: number, newIndex: number) => { + if (!ref) return + const store = getTabsStore(ref) + const newOpenTabs = [...store.openTabs] + const [removedTab] = newOpenTabs.splice(oldIndex, 1) + newOpenTabs.splice(newIndex, 0, removedTab) + store.openTabs = newOpenTabs +} + +// Function to make a tab permanent +export const makeTabPermanent = (ref: string | undefined, tabId: string) => { + if (!ref) return + const store = getTabsStore(ref) + const tab = store.tabsMap[tabId] + + if (tab?.isPreview) { + tab.isPreview = false + store.previewTabId = undefined + // Add to recent items when preview tab becomes permanent + addRecentItem(ref, tab) + } +} + +// make the active tab permanent if it is a preview +export const makeActiveTabPermanent = (ref?: string) => { + if (!ref) return false + const store = getTabsStore(ref) + + if (store.activeTab && store.tabsMap[store.activeTab]?.isPreview) { + makeTabPermanent(ref, store.activeTab) + return true + } + return false +} + +// handle navigation to a specific tab +export const handleTabNavigation = (ref: string | undefined, id: string, router: NextRouter) => { + if (!ref) return + + const store = getTabsStore(ref) + const tab = store.tabsMap[id] + if (!tab) return + + store.activeTab = id + + // Add to recent items when navigating to a non-preview, non-new tab + if (!tab.isPreview) addRecentItem(ref, tab) + + switch (tab.type) { + case 'sql': + const schema = (router.query.schema as string) || 'public' + router.push(`/project/${router.query.ref}/sql/${tab.metadata?.sqlId}?schema=${schema}`) + break + case 'r': + case 'v': + case 'm': + case 'f': + case 'p': + router.push( + `/project/${router.query.ref}/editor/${tab.metadata?.tableId}?schema=${tab.metadata?.schema}` + ) + break + } +} + +// Function to handle closing a tab +export const handleTabClose = ({ + ref, + id, + router, + editor, + onClose, + onClearDashboardHistory, +}: { + ref: string | undefined + id: string + router: NextRouter + editor?: 'sql' | 'table' + onClose?: (id: string) => void + onClearDashboardHistory: () => void +}) => { + if (!ref) return + + const store = getTabsStore(ref) + // tabs without the one we're closing + const currentTab = store.tabsMap[id] + const currentTabs = Object.values(store.tabsMap).filter((tab) => tab.id !== id) + + const nextTabId = !editor + ? undefined + : currentTabs.filter((tab) => { + return editorEntityTypes[editor]?.includes(tab.type) + })[0]?.id + delete store.tabsMap[id] + + if (currentTab) { + // Update store + // If the tab being removed is logged in the store, update the open tabs + store.openTabs = [...currentTabs.map((tab) => tab.id).filter((id) => id !== currentTab.id)] + } + + // Check if there is a preview tab and if it matches the tab being closed + if (store.previewTabId) { + if (store.previewTabId === id) { + // remove the preview tab if it matches the tab being closed + store.previewTabId = undefined + } + } + + // [Joshen] Only navigate away if we're closing the tab that's currently in focus + if (store.activeTab === id || id === 'new') { + if (nextTabId) { + store.activeTab = nextTabId + handleTabNavigation(ref, nextTabId, router) + } else { + onClearDashboardHistory() + + // If no tabs of same type, go to the home of the current section + switch (currentTab?.type) { + case 'sql': + router.push(`/project/${router.query.ref}/sql`) + break + case 'r': + case 'v': + case 'm': + case 'f': + case 'p': + router.push(`/project/${router.query.ref}/editor`) + break + default: + router.push(`/project/${router.query.ref}/${editor}`) + } + } + } + + onClose?.(id) +} + +// Function to handle the end of a tab drag event +export const handleTabDragEnd = ( + ref: string | undefined, + oldIndex: number, + newIndex: number, + tabId: string, + router: any +) => { + if (!ref) return + const store = getTabsStore(ref) + // Make permanent if needed + const draggedTab = store.tabsMap[tabId] + if (draggedTab?.isPreview) { + makeTabPermanent(ref, tabId) + } + + // Reorder tabs + const newOpenTabs = [...store.openTabs] + newOpenTabs.splice(oldIndex, 1) + newOpenTabs.splice(newIndex, 0, tabId) + + store.openTabs = newOpenTabs + store.activeTab = tabId + + // Handle navigation + handleTabNavigation(ref, tabId, router) +} + +type CreateTabIdParams = { + r: { id: number } + v: { id: number } + m: { id: number } + f: { id: number } + p: { id: number } + sql: { id: string } + schema: { schema: string } + view: never + function: never + new: never +} + +// Function to create a unique tab ID based on type and parameters +export function createTabId(type: T, params: CreateTabIdParams[T]): string { + switch (type) { + case 'r': + return `r-${(params as CreateTabIdParams['r']).id}` + case 'v': + return `v-${(params as CreateTabIdParams['v']).id}` + case 'm': + return `m-${(params as CreateTabIdParams['m']).id}` + case 'f': + return `f-${(params as CreateTabIdParams['f']).id}` + case 'p': + return `p-${(params as CreateTabIdParams['p']).id}` + case 'sql': + return `sql-${(params as CreateTabIdParams['sql']).id}` + default: + return '' + } +} + +// Object mapping editor types to their corresponding tab types +export const editorEntityTypes = { + table: ['r', 'v', 'm', 'f', 'p'], + sql: ['sql'], +} + +// Function to remove tabs based on their editor type +export function removeTabsByEditor(ref: string | undefined, type: 'table' | 'sql') { + if (!ref) return + const store = getTabsStore(ref) + + // Get tab IDs to remove + const tabIdsToRemove = store.openTabs.filter((id) => { + return editorEntityTypes[type].some((entityType) => id.startsWith(`${entityType}-`)) + }) + + removeTabs(ref, tabIdsToRemove) +} diff --git a/apps/studio/styles/monaco.scss b/apps/studio/styles/monaco.scss index 7ebacfe0974..47a90618bea 100644 --- a/apps/studio/styles/monaco.scss +++ b/apps/studio/styles/monaco.scss @@ -13,10 +13,16 @@ border: 0 !important; } +[data-theme='dark'] .monaco-editor, +[data-theme='dark'] .monaco-diff-editor { + --vscode-editor-background: hsl(var(--background-surface-100)) !important; + --vscode-editorGutter-background: hsl(var(--background-surface-100)) !important; + color-scheme: dark; +} .monaco-editor, .monaco-diff-editor { - --vscode-editor-background: hsl(var(--background-surface-200)) !important; - --vscode-editorGutter-background: hsl(var(--background-surface-200)) !important; + --vscode-editor-background: hsl(var(--background-dash-sidebar)) !important; + --vscode-editorGutter-background: hsl(var(--background-dash-sidebar)) !important; } .gutter { diff --git a/packages/ui/src/components/TreeView/TreeView.tsx b/packages/ui/src/components/TreeView/TreeView.tsx index 0c7d72fa7f2..8fa03898fcb 100644 --- a/packages/ui/src/components/TreeView/TreeView.tsx +++ b/packages/ui/src/components/TreeView/TreeView.tsx @@ -133,23 +133,35 @@ const TreeViewItem = forwardRef< } }, [isLoading]) + const handleBlur = () => { + onEditSubmit?.(localValueState) + } + const handleSubmit = (e: React.FormEvent) => { e.preventDefault() onEditSubmit?.(localValueState) } - const handleBlur = () => { - onEditSubmit?.(localValueState) - } + // [Joshen] These properties were causing console errors as they were getting passed as props to the parent div + const { + isDisabled, + isHalfSelected, + handleSelect, + handleExpand, + treeState, + dispatch, + ...divProps + } = props as any return (
    =10.0.0'} + '@dnd-kit/accessibility@3.1.1': + resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} + peerDependencies: + react: '>=16.8.0' + + '@dnd-kit/core@6.3.1': + resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@dnd-kit/sortable@8.0.0': + resolution: {integrity: sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g==} + peerDependencies: + '@dnd-kit/core': ^6.1.0 + react: '>=16.8.0' + + '@dnd-kit/utilities@3.2.2': + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} + peerDependencies: + react: '>=16.8.0' + '@effect-ts/core@0.60.5': resolution: {integrity: sha512-qi1WrtJA90XLMnj2hnUszW9Sx4dXP03ZJtCc5DiUBIOhF4Vw7plfb65/bdBySPoC9s7zy995TdUX1XBSxUkl5w==} @@ -16370,6 +16401,31 @@ snapshots: '@discoveryjs/json-ext@0.5.7': {} + '@dnd-kit/accessibility@3.1.1(react@18.2.0)': + dependencies: + react: 18.2.0 + tslib: 2.6.2 + + '@dnd-kit/core@6.3.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': + dependencies: + '@dnd-kit/accessibility': 3.1.1(react@18.2.0) + '@dnd-kit/utilities': 3.2.2(react@18.2.0) + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + tslib: 2.6.2 + + '@dnd-kit/sortable@8.0.0(@dnd-kit/core@6.3.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@dnd-kit/utilities': 3.2.2(react@18.2.0) + react: 18.2.0 + tslib: 2.6.2 + + '@dnd-kit/utilities@3.2.2(react@18.2.0)': + dependencies: + react: 18.2.0 + tslib: 2.6.2 + '@effect-ts/core@0.60.5': dependencies: '@effect-ts/system': 0.57.5 @@ -17364,7 +17420,7 @@ snapshots: '@motionone/easing@10.16.3': dependencies: '@motionone/utils': 10.16.3 - tslib: 2.6.2 + tslib: 2.8.1 '@motionone/generators@10.16.4': dependencies: @@ -26452,7 +26508,7 @@ snapshots: lower-case@2.0.2: dependencies: - tslib: 2.6.2 + tslib: 2.8.1 lowlight@1.20.0: dependencies: @@ -29334,7 +29390,7 @@ snapshots: dependencies: react: 18.2.0 react-style-singleton: 2.2.3(@types/react@18.3.3)(react@18.2.0) - tslib: 2.6.2 + tslib: 2.8.1 optionalDependencies: '@types/react': 18.3.3 @@ -29415,7 +29471,7 @@ snapshots: dependencies: get-nonce: 1.0.1 react: 18.2.0 - tslib: 2.6.2 + tslib: 2.8.1 optionalDependencies: '@types/react': 18.3.3 @@ -31577,11 +31633,11 @@ snapshots: upper-case-first@2.0.2: dependencies: - tslib: 2.6.2 + tslib: 2.8.1 upper-case@2.0.2: dependencies: - tslib: 2.6.2 + tslib: 2.8.1 uqr@0.1.2: {} @@ -31608,7 +31664,7 @@ snapshots: use-callback-ref@1.3.3(@types/react@18.3.3)(react@18.2.0): dependencies: react: 18.2.0 - tslib: 2.6.2 + tslib: 2.8.1 optionalDependencies: '@types/react': 18.3.3 @@ -31632,7 +31688,7 @@ snapshots: dependencies: detect-node-es: 1.1.0 react: 18.2.0 - tslib: 2.6.2 + tslib: 2.8.1 optionalDependencies: '@types/react': 18.3.3 diff --git a/tests/studio-tests/tests/common-functionality/table-editor.spec.ts b/tests/studio-tests/tests/common-functionality/table-editor.spec.ts index 7d4322545c3..d05fe4462f7 100644 --- a/tests/studio-tests/tests/common-functionality/table-editor.spec.ts +++ b/tests/studio-tests/tests/common-functionality/table-editor.spec.ts @@ -30,6 +30,7 @@ test.describe('Table Editor', () => { // The page has been loaded with the table data, we can now interact with the page await page.getByRole('button', { name: 'New table', exact: true }).click() + await page.getByTestId('table-name-input').waitFor({ state: 'visible' }) await page.getByTestId('table-name-input').click() await page.getByTestId('table-name-input').fill(tableName)