mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
feat(studio): logs snippets in SQL editor nav, search, and tabs (#48457)
## What PR 7 of the SQL-editor query-source (Database vs Logs) stack. Surfaces `log_sql` snippets as a distinct query source across the SQL editor sidebar. Stacked on **`charislam/toolbar-ui-creation-flow`** (PR 6 — toolbar UI + creation flow); review/merge that first. Nothing is user-visible until the flags roll out — every entry point requires **both** `sqlEditorLogsSource` **and** `otelLegacyLogs`. ## Changes - **Nav** — a flag-gated **Logs** section (`LogsSnippetsSection`) backed by its own single-type `log_sql` query. The active snippet is injected only into the section it belongs to, via a shared `withActiveSnippet(snippets, active, belongsPredicate)` helper (also DRYs the private/favorites/shared injections). - **Search** (`SearchList`) — a **Logs** result group with a shared, extracted `SqlSnippetTree`; the "N results found" count now sums database + logs, with loading/empty states covering both queries. - **Tabs** — an immutable `sqlSource` field on tab/recent-item metadata (set at tab creation, lazily backfilled once the snippet loads via `useEffectEvent`), and a distinct `ScrollText` icon via a shared `LogsSnippetIcon`. Tab cleanup treats `log_sql` tabs as live and only prunes them when logs data is authoritative (`canPruneLogsTabs`), so a disabled/erroring logs query never wrongly deletes logs tabs or blocks database-tab cleanup. - **Data layer** — `useSqlSnippetsQuery` gains an optional `type` param so logs reuse the same `SnippetWithContent` shape as the other sections (no casts). ## Tests - `state/tabs.test.ts` — `sqlSource` backfill + creation-time carry-through. - `components/layouts/Tabs/Tabs.utils.test.tsx` — cleanup prunes stale database/logs snippets, keeps live ones, and preserves logs tabs when logs data isn't authoritative. ## Verification - `pnpm --filter studio typecheck` ✓ - `pnpm --filter studio run lint:ratchet` ✓ - `pnpm test:studio` (affected suites) ✓ <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added a collapsible Logs section to the SQL editor sidebar for browsing, sorting, selecting, renaming, and deleting log queries. * Expanded SQL search with separate, paginated results for database and log queries. * Added dedicated log-query icons across navigation, tabs, previews, and recent items. * **Bug Fixes** * Improved tab and recent-item cleanup while preserving active log queries and accurate source metadata. * **Tests** * Added coverage for log tab cleanup and SQL source metadata synchronization. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
1 parent
de5d3cd115
commit
b3c5c9fc04
18 files changed
+805
-175
No files matched your search
+136
@@ -0,0 +1,136 @@
|
||||
import { keepPreviousData } from '@tanstack/react-query'
|
||||
import { useParams } from 'common'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import {
|
||||
InnerSideBarEmptyPanel,
|
||||
InnerSideMenuCollapsible,
|
||||
InnerSideMenuCollapsibleContent,
|
||||
InnerSideMenuCollapsibleTrigger,
|
||||
} from 'ui-patterns/InnerSideMenu'
|
||||
|
||||
import { SQLEditorLoadingSnippets } from './SQLEditorLoadingSnippets'
|
||||
import {
|
||||
formatFolderResponseForTreeView,
|
||||
getLastItemIds,
|
||||
ROOT_NODE,
|
||||
withActiveSnippet,
|
||||
} from './SQLEditorNav.utils'
|
||||
import { SqlSnippetTree } from './SqlSnippetTree'
|
||||
import { getSnippetSource } from '@/components/interfaces/SQLEditor/querySource'
|
||||
import { useContentCountQuery } from '@/data/content/content-count-query'
|
||||
import { Snippet } from '@/data/content/sql-folders-query'
|
||||
import { useSqlSnippetsQuery } from '@/data/content/sql-snippets-query'
|
||||
import { useLatest } from '@/hooks/misc/useLatest'
|
||||
|
||||
interface LogsSnippetsSectionProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
sort: 'inserted_at' | 'name'
|
||||
/** The currently open snippet, surfaced in the list before the query fetches it. */
|
||||
activeSnippet?: Snippet
|
||||
selectedSnippetIds: string[]
|
||||
/** Bubbles loaded logs snippets up for tab cleanup, mirroring onFolderContentsChange. */
|
||||
onSnippetsLoaded: (info: {
|
||||
snippets: Snippet[]
|
||||
/**
|
||||
* Whether `snippets` is the complete set of logs snippets. Only true once every
|
||||
* page has been fetched — cleanup prunes tabs whose snippet is absent from the
|
||||
* list, and a snippet on an unfetched page would otherwise look deleted.
|
||||
*/
|
||||
isComplete: boolean
|
||||
isSettled: boolean
|
||||
}) => void
|
||||
onSelectDelete: (snippet: Snippet) => void
|
||||
onSelectRename: (snippet: Snippet) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* A separate single-type `log_sql` query — logs snippets are a distinct backend and
|
||||
* don't participate in folders, so they get their own flat section rather than merging
|
||||
* with the cursor-paginated `sql` sections.
|
||||
*/
|
||||
export const LogsSnippetsSection = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
sort,
|
||||
activeSnippet,
|
||||
selectedSnippetIds,
|
||||
onSnippetsLoaded,
|
||||
onSelectDelete,
|
||||
onSelectRename,
|
||||
}: LogsSnippetsSectionProps) => {
|
||||
const { ref: projectRef } = useParams()
|
||||
|
||||
const { data, isLoading, isSuccess, isError, hasNextPage, fetchNextPage, isFetchingNextPage } =
|
||||
useSqlSnippetsQuery(
|
||||
{ projectRef, type: 'log_sql', sort },
|
||||
{ placeholderData: keepPreviousData }
|
||||
)
|
||||
|
||||
const { data: countData } = useContentCountQuery({ projectRef, type: 'log_sql' })
|
||||
const numSnippets = (countData?.private ?? 0) + (countData?.shared ?? 0)
|
||||
|
||||
const snippets = useMemo(() => {
|
||||
const pageSnippets = data?.pages.flatMap((page) => page.contents ?? []) ?? []
|
||||
|
||||
return withActiveSnippet<Snippet>(
|
||||
pageSnippets,
|
||||
activeSnippet,
|
||||
(s) => getSnippetSource(s) === 'logs'
|
||||
)
|
||||
.map((x) => ({ ...x, folder_id: null }))
|
||||
.sort((a, b) => {
|
||||
if (sort === 'name') return a.name.localeCompare(b.name)
|
||||
return new Date(b.inserted_at).valueOf() - new Date(a.inserted_at).valueOf()
|
||||
})
|
||||
}, [data?.pages, activeSnippet, sort])
|
||||
|
||||
const treeState = useMemo(
|
||||
() =>
|
||||
snippets.length === 0
|
||||
? [ROOT_NODE]
|
||||
: formatFolderResponseForTreeView({ contents: snippets, folders: [] }),
|
||||
[snippets]
|
||||
)
|
||||
const lastItemIds = useMemo(() => getLastItemIds(treeState), [treeState])
|
||||
|
||||
const onSnippetsLoadedRef = useLatest(onSnippetsLoaded)
|
||||
useEffect(() => {
|
||||
onSnippetsLoadedRef.current({
|
||||
snippets,
|
||||
isComplete: isSuccess && !hasNextPage,
|
||||
isSettled: isSuccess || isError,
|
||||
})
|
||||
}, [snippets, isSuccess, isError, hasNextPage])
|
||||
|
||||
return (
|
||||
<InnerSideMenuCollapsible className="px-0" open={open} onOpenChange={onOpenChange}>
|
||||
<InnerSideMenuCollapsibleTrigger
|
||||
title={`Logs ${numSnippets > 0 ? ` (${numSnippets})` : ''}`}
|
||||
/>
|
||||
<InnerSideMenuCollapsibleContent className="group-data-open:pt-2">
|
||||
{isLoading && <SQLEditorLoadingSnippets />}
|
||||
{!isLoading && snippets.length === 0 && (
|
||||
<InnerSideBarEmptyPanel
|
||||
className="mx-2"
|
||||
title="No logs queries"
|
||||
description="Create a logs query from the toolbar to explore your project's logs with SQL."
|
||||
/>
|
||||
)}
|
||||
{!isLoading && snippets.length > 0 && (
|
||||
<SqlSnippetTree
|
||||
ariaLabel="logs-snippets"
|
||||
data={treeState}
|
||||
lastItemIds={lastItemIds}
|
||||
selectedSnippetIds={selectedSnippetIds}
|
||||
hasNextPage={hasNextPage}
|
||||
fetchNextPage={fetchNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
onSelectDelete={onSelectDelete}
|
||||
onSelectRename={onSelectRename}
|
||||
/>
|
||||
)}
|
||||
</InnerSideMenuCollapsibleContent>
|
||||
</InnerSideMenuCollapsible>
|
||||
)
|
||||
}
|
||||
+2
@@ -2,6 +2,7 @@ export type SectionState = {
|
||||
shared: boolean
|
||||
favorite: boolean
|
||||
private: boolean
|
||||
logs: boolean
|
||||
community: boolean
|
||||
}
|
||||
|
||||
@@ -9,5 +10,6 @@ export const DEFAULT_SECTION_STATE: SectionState = {
|
||||
shared: false,
|
||||
favorite: false,
|
||||
private: true,
|
||||
logs: false,
|
||||
community: true,
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { keepPreviousData } from '@tanstack/react-query'
|
||||
import { IS_PLATFORM, LOCAL_STORAGE_KEYS, useParams } from 'common'
|
||||
import { IS_PLATFORM, LOCAL_STORAGE_KEYS, useFlag, useParams } from 'common'
|
||||
import { Heart } from 'lucide-react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
@@ -15,15 +15,22 @@ import {
|
||||
} from 'ui-patterns/InnerSideMenu'
|
||||
|
||||
import { DeleteSnippetsModal } from './DeleteSnippetsModal'
|
||||
import { LogsSnippetsSection } from './LogsSnippetsSection'
|
||||
import { ReferenceSnippetsSection } from './ReferenceSnippetsSection'
|
||||
import { ShareSnippetModal } from './ShareSnippetModal'
|
||||
import { SQLEditorLoadingSnippets } from './SQLEditorLoadingSnippets'
|
||||
import { DEFAULT_SECTION_STATE, type SectionState } from './SQLEditorNav.constants'
|
||||
import { formatFolderResponseForTreeView, getLastItemIds, ROOT_NODE } from './SQLEditorNav.utils'
|
||||
import {
|
||||
formatFolderResponseForTreeView,
|
||||
getLastItemIds,
|
||||
ROOT_NODE,
|
||||
withActiveSnippet,
|
||||
} from './SQLEditorNav.utils'
|
||||
import { SQLEditorTreeViewItem } from './SQLEditorTreeViewItem'
|
||||
import { UnshareSnippetModal } from './UnshareSnippetModal'
|
||||
import { DownloadSnippetModal } from '@/components/interfaces/SQLEditor/DownloadSnippetModal'
|
||||
import { MoveQueryModal } from '@/components/interfaces/SQLEditor/MoveQueryModal'
|
||||
import { getSnippetSource } from '@/components/interfaces/SQLEditor/querySource'
|
||||
import { RenameQueryModal } from '@/components/interfaces/SQLEditor/RenameQueryModal'
|
||||
import { generateSnippetTitle } from '@/components/interfaces/SQLEditor/SQLEditor.constants'
|
||||
import { createSqlSnippetSkeletonV2 } from '@/components/interfaces/SQLEditor/SQLEditor.utils'
|
||||
@@ -65,8 +72,16 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
|
||||
shared: showSharedSnippets,
|
||||
favorite: showFavoriteSnippets,
|
||||
private: showPrivateSnippets,
|
||||
logs: showLogsSnippets,
|
||||
} = sectionVisibility
|
||||
|
||||
// Both flags gate the entry point: `sqlEditorLogsSource` enables the feature and
|
||||
// `otelLegacyLogs` confirms the org's logs live in the ClickHouse backend a logs
|
||||
// snippet queries.
|
||||
const isLogsSourceEnabled = useFlag('sqlEditorLogsSource')
|
||||
const isOtelLogsEnabled = useFlag('otelLegacyLogs')
|
||||
const canShowLogsSection = isLogsSourceEnabled && isOtelLogsEnabled
|
||||
|
||||
const [showMoveModal, setShowMoveModal] = useState(false)
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false)
|
||||
const [showRenameModal, setShowRenameModal] = useState(false)
|
||||
@@ -131,10 +146,11 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
|
||||
}
|
||||
)
|
||||
|
||||
if (snippet && snippet.visibility === 'user' && !snippetInfo.snippetIds.has(snippet.id)) {
|
||||
snippetInfo.snippetIds.add(snippet.id)
|
||||
snippetInfo.snippets = [...snippetInfo.snippets, snippet]
|
||||
}
|
||||
snippetInfo.snippets = withActiveSnippet(
|
||||
snippetInfo.snippets,
|
||||
snippet,
|
||||
(s) => s.visibility === 'user' && getSnippetSource(s) !== 'logs'
|
||||
)
|
||||
|
||||
return snippetInfo
|
||||
}, [privateSnippetsPages?.pages, subResults, isLoading, isPlaceholderData, isFetching, snippet])
|
||||
@@ -204,11 +220,11 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
|
||||
)
|
||||
|
||||
const favoriteSnippets = useMemo(() => {
|
||||
let snippets = favoriteSqlSnippetsData?.pages.flatMap((page) => page.contents ?? []) ?? []
|
||||
|
||||
if (snippet && snippet.favorite && !snippets.find((x) => x.id === snippet.id)) {
|
||||
snippets.push(snippet)
|
||||
}
|
||||
const snippets = withActiveSnippet(
|
||||
favoriteSqlSnippetsData?.pages.flatMap((page) => page.contents ?? []) ?? [],
|
||||
snippet,
|
||||
(s) => !!s.favorite && getSnippetSource(s) !== 'logs'
|
||||
)
|
||||
|
||||
return (
|
||||
snippets
|
||||
@@ -255,11 +271,11 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
|
||||
)
|
||||
|
||||
const sharedSnippets = useMemo(() => {
|
||||
let snippets = sharedSqlSnippetsData?.pages.flatMap((page) => page.contents ?? []) ?? []
|
||||
|
||||
if (snippet && snippet.visibility === 'project' && !snippets.find((x) => x.id === snippet.id)) {
|
||||
snippets.push(snippet)
|
||||
}
|
||||
const snippets = withActiveSnippet(
|
||||
sharedSqlSnippetsData?.pages.flatMap((page) => page.contents ?? []) ?? [],
|
||||
snippet,
|
||||
(s) => s.visibility === 'project' && getSnippetSource(s) !== 'logs'
|
||||
)
|
||||
|
||||
return (
|
||||
snippets.sort((a, b) => {
|
||||
@@ -284,12 +300,21 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
|
||||
[projectSnippetsTreeState]
|
||||
)
|
||||
|
||||
// The Logs section owns its own query and bubbles loaded snippets here so tab
|
||||
// cleanup treats them as live (and prunes stale ones).
|
||||
const [logsSnippetsInView, setLogsSnippetsInView] = useState<{
|
||||
snippets: Snippet[]
|
||||
isComplete: boolean
|
||||
isSettled: boolean
|
||||
}>({ snippets: [], isComplete: false, isSettled: false })
|
||||
|
||||
const allSnippetsInView = useMemo(
|
||||
() => [
|
||||
...(privateSnippetsPages?.pages.flatMap((x) => x.contents) ?? []),
|
||||
...(sharedSqlSnippetsData?.pages.flatMap((x) => x.contents) ?? []),
|
||||
...(privateSnippetsPages?.pages.flatMap((x) => x.contents ?? []) ?? []),
|
||||
...(sharedSqlSnippetsData?.pages.flatMap((x) => x.contents ?? []) ?? []),
|
||||
...logsSnippetsInView.snippets,
|
||||
],
|
||||
[privateSnippetsPages, sharedSqlSnippetsData]
|
||||
[privateSnippetsPages, sharedSqlSnippetsData, logsSnippetsInView.snippets]
|
||||
)
|
||||
|
||||
// ==========================
|
||||
@@ -404,7 +429,12 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
|
||||
|
||||
useEffect(() => {
|
||||
if (snippet !== undefined && isSuccess) {
|
||||
if (snippet.visibility === 'project') {
|
||||
// Source is checked before visibility: a logs snippet lives in the Logs section
|
||||
// whatever its visibility, so branching on visibility first would open Private
|
||||
// (or Shared) and leave the section the snippet is actually in collapsed.
|
||||
if (getSnippetSource(snippet) === 'logs') {
|
||||
setSectionVisibility({ ...sectionVisibility, logs: true })
|
||||
} else if (snippet.visibility === 'project') {
|
||||
setSectionVisibility({ ...sectionVisibility, shared: true })
|
||||
} else if (snippet.visibility === 'user') {
|
||||
setSectionVisibility({ ...sectionVisibility, private: true })
|
||||
@@ -457,10 +487,24 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
|
||||
|
||||
const sqlEditorTabsCleanup = useSqlEditorTabsCleanup()
|
||||
useEffect(() => {
|
||||
if (isSuccess) {
|
||||
sqlEditorTabsCleanup({ snippets: allSnippetsInView as any })
|
||||
// Wait for the logs query to settle (when enabled) so a logs failure doesn't
|
||||
// freeze database-tab cleanup. Logs tabs are only prunable once every logs page
|
||||
// has been fetched — until then the list is partial and a tab whose snippet sits
|
||||
// on an unfetched page would be pruned as stale, so they're preserved instead.
|
||||
if (isSuccess && (!canShowLogsSection || logsSnippetsInView.isSettled)) {
|
||||
sqlEditorTabsCleanup({
|
||||
snippets: allSnippetsInView,
|
||||
canPruneLogsTabs: canShowLogsSection && logsSnippetsInView.isComplete,
|
||||
})
|
||||
}
|
||||
}, [allSnippetsInView, isSuccess, sqlEditorTabsCleanup])
|
||||
}, [
|
||||
allSnippetsInView,
|
||||
isSuccess,
|
||||
canShowLogsSection,
|
||||
logsSnippetsInView.isComplete,
|
||||
logsSnippetsInView.isSettled,
|
||||
sqlEditorTabsCleanup,
|
||||
])
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -757,6 +801,31 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
|
||||
|
||||
<InnerSideMenuSeparator />
|
||||
|
||||
{canShowLogsSection && (
|
||||
<>
|
||||
<LogsSnippetsSection
|
||||
open={showLogsSnippets}
|
||||
onOpenChange={(value) =>
|
||||
setSectionVisibility({ ...(sectionVisibility ?? DEFAULT_SECTION_STATE), logs: value })
|
||||
}
|
||||
sort={sort}
|
||||
activeSnippet={snippet}
|
||||
selectedSnippetIds={selectedSnippets.map((x) => x.id)}
|
||||
onSnippetsLoaded={setLogsSnippetsInView}
|
||||
onSelectDelete={(snippet) => {
|
||||
setShowDeleteModal(true)
|
||||
setSelectedSnippets([snippet])
|
||||
}}
|
||||
onSelectRename={(snippet) => {
|
||||
setShowRenameModal(true)
|
||||
setSelectedSnippetToRename(snippet)
|
||||
}}
|
||||
/>
|
||||
|
||||
<InnerSideMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
<ReferenceSnippetsSection />
|
||||
|
||||
<InnerSideMenuSeparator />
|
||||
|
||||
@@ -53,6 +53,27 @@ export const formatFolderResponseForTreeView = (
|
||||
return [root, ...formattedFolders, ...formattedContents]
|
||||
}
|
||||
|
||||
/**
|
||||
* Append the active snippet to a section's list when it belongs there (per the
|
||||
* section's predicate) and isn't already present. Each nav section lists
|
||||
* server-filtered pages, so a just-opened or just-created snippet may not appear
|
||||
* until a refetch — this surfaces it immediately, in the one section it belongs to.
|
||||
*/
|
||||
export function withActiveSnippet<T extends { id: string }>(
|
||||
snippets: T[],
|
||||
activeSnippet: T | undefined,
|
||||
belongsInSection: (snippet: T) => boolean
|
||||
): T[] {
|
||||
if (
|
||||
activeSnippet !== undefined &&
|
||||
belongsInSection(activeSnippet) &&
|
||||
!snippets.some((snippet) => snippet.id === activeSnippet.id)
|
||||
) {
|
||||
return [...snippets, activeSnippet]
|
||||
}
|
||||
return snippets
|
||||
}
|
||||
|
||||
export function getLastItemIds(items: TreeViewItemProps[]) {
|
||||
let lastItemIds = new Set<string>()
|
||||
|
||||
|
||||
+11
@@ -30,6 +30,7 @@ import {
|
||||
|
||||
import { getSnippetSource } from '@/components/interfaces/SQLEditor/querySource'
|
||||
import { createSqlSnippetSkeletonV2 } from '@/components/interfaces/SQLEditor/SQLEditor.utils'
|
||||
import { LogsSnippetIcon } from '@/components/ui/EntityTypeIcon'
|
||||
import { getContentById, getSqlSnippetById } from '@/data/content/content-id-query'
|
||||
import { useSQLSnippetFolderContentsQuery } from '@/data/content/sql-folder-contents-query'
|
||||
import { Snippet } from '@/data/content/sql-folders-query'
|
||||
@@ -111,6 +112,8 @@ export const SQLEditorTreeViewItem = ({
|
||||
const isSharedSnippet = element.metadata.visibility === 'project'
|
||||
const isFavorite = element.metadata.favorite
|
||||
|
||||
const isLogsSnippet = getSnippetSource(element.metadata) === 'logs'
|
||||
|
||||
const isEditing = isFolderEditing(status)
|
||||
const isSaving = isFolderSaving(status)
|
||||
|
||||
@@ -247,6 +250,14 @@ export const SQLEditorTreeViewItem = ({
|
||||
isPreview={props.isPreview}
|
||||
isEditing={isEditing}
|
||||
isLoading={(isEnabled && isLoading) || isSaving}
|
||||
icon={
|
||||
isLogsSnippet ? (
|
||||
<LogsSnippetIcon
|
||||
size={16}
|
||||
className="w-5 h-5 shrink-0 text-foreground-muted group-aria-selected:text-foreground"
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
onEditSubmit={(value) => {
|
||||
if (onEditSave !== undefined) onEditSave(value)
|
||||
}}
|
||||
|
||||
@@ -1,29 +1,45 @@
|
||||
import { keepPreviousData } from '@tanstack/react-query'
|
||||
import { useParams } from 'common'
|
||||
import { useFlag, useParams } from 'common'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { TreeView } from 'ui'
|
||||
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
import { DeleteSnippetsModal } from './DeleteSnippetsModal'
|
||||
import { ShareSnippetModal } from './ShareSnippetModal'
|
||||
import { formatFolderResponseForTreeView, getLastItemIds } from './SQLEditorNav.utils'
|
||||
import { SQLEditorTreeViewItem } from './SQLEditorTreeViewItem'
|
||||
import { SqlSnippetTree } from './SqlSnippetTree'
|
||||
import { UnshareSnippetModal } from './UnshareSnippetModal'
|
||||
import { DownloadSnippetModal } from '@/components/interfaces/SQLEditor/DownloadSnippetModal'
|
||||
import { RenameQueryModal } from '@/components/interfaces/SQLEditor/RenameQueryModal'
|
||||
import { useContentCountQuery } from '@/data/content/content-count-query'
|
||||
import { useContentInfiniteQuery } from '@/data/content/content-infinite-query'
|
||||
import { Snippet, SNIPPET_PAGE_LIMIT } from '@/data/content/sql-folders-query'
|
||||
import { createTabId, useTabsStateSnapshot } from '@/state/tabs'
|
||||
import { Snippet } from '@/data/content/sql-folders-query'
|
||||
import { useSqlSnippetsQuery } from '@/data/content/sql-snippets-query'
|
||||
|
||||
interface SearchListProps {
|
||||
search: string
|
||||
}
|
||||
|
||||
/** Uppercase section heading shown above the Database and Logs result groups. */
|
||||
const SearchGroupHeading = ({ children }: { children: string }) => (
|
||||
<p className="px-4 pb-2 pt-1 text-xs font-medium uppercase text-foreground-muted">{children}</p>
|
||||
)
|
||||
|
||||
/**
|
||||
* Flatten paginated snippet results into the flat (folderless) tree the search view
|
||||
* renders. Keyed on `pages` (a stable reference from React Query) so the flatMap and
|
||||
* tree build run once per data change rather than on every render.
|
||||
*/
|
||||
function useSnippetSearchTree(pages: { contents: Snippet[] }[] | undefined) {
|
||||
return useMemo(() => {
|
||||
const flat = (pages ?? [])
|
||||
.flatMap((page) => page.contents ?? [])
|
||||
.map((snippet) => ({ ...snippet, folder_id: null }))
|
||||
const treeState = formatFolderResponseForTreeView({ folders: [], contents: flat })
|
||||
return { treeState, lastItemIds: getLastItemIds(treeState), count: flat.length }
|
||||
}, [pages])
|
||||
}
|
||||
|
||||
export const SearchList = ({ search }: SearchListProps) => {
|
||||
const { id } = useParams()
|
||||
const tabs = useTabsStateSnapshot()
|
||||
const { ref: projectRef } = useParams()
|
||||
|
||||
const [selectedSnippetToShare, setSelectedSnippetToShare] = useState<Snippet>()
|
||||
@@ -32,120 +48,121 @@ export const SearchList = ({ search }: SearchListProps) => {
|
||||
const [selectedSnippetToRename, setSelectedSnippetToRename] = useState<Snippet>()
|
||||
const [selectedSnippetToDelete, setSelectedSnippetToDelete] = useState<Snippet>()
|
||||
|
||||
// Logs snippets are a separate backend and don't share the `sql` cursor, so the
|
||||
// search runs a second single-type query and renders them under their own group.
|
||||
// Gated by both flags, mirroring the nav's Logs section.
|
||||
const isLogsSourceEnabled = useFlag('sqlEditorLogsSource')
|
||||
const isOtelLogsEnabled = useFlag('otelLegacyLogs')
|
||||
const canShowLogsSection = isLogsSourceEnabled && isOtelLogsEnabled
|
||||
|
||||
const searchName = search.length === 0 ? undefined : search
|
||||
|
||||
const {
|
||||
data,
|
||||
isPending: isLoading,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
isFetchingNextPage,
|
||||
} = useContentInfiniteQuery(
|
||||
{
|
||||
projectRef,
|
||||
type: 'sql',
|
||||
limit: SNIPPET_PAGE_LIMIT,
|
||||
name: search.length === 0 ? undefined : search,
|
||||
},
|
||||
} = useSqlSnippetsQuery(
|
||||
{ projectRef, type: 'sql', name: searchName },
|
||||
{ placeholderData: keepPreviousData }
|
||||
)
|
||||
|
||||
const { data: count, isPending: isLoadingCount } = useContentCountQuery(
|
||||
{
|
||||
projectRef,
|
||||
type: 'sql',
|
||||
name: search,
|
||||
},
|
||||
{ projectRef, type: 'sql', name: search },
|
||||
{ placeholderData: keepPreviousData }
|
||||
)
|
||||
const totalNumber = count ? count.private + count.shared : 0
|
||||
|
||||
const snippets = useMemo(
|
||||
// [Joshen] Set folder_id to null to ensure flat list
|
||||
() => data?.pages.flatMap((page) => page.content.map((x) => ({ ...x, folder_id: null }))),
|
||||
[data?.pages]
|
||||
const { data: logsCount, isPending: isLoadingLogsCount } = useContentCountQuery(
|
||||
{ projectRef, type: 'log_sql', name: search },
|
||||
{ enabled: canShowLogsSection, placeholderData: keepPreviousData }
|
||||
)
|
||||
const treeState = formatFolderResponseForTreeView({ folders: [], contents: snippets as any })
|
||||
|
||||
const snippetsLastItemIds = useMemo(() => getLastItemIds(treeState), [treeState])
|
||||
const databaseCount = count ? count.private + count.shared : 0
|
||||
const logsResultCount = canShowLogsSection && logsCount ? logsCount.private + logsCount.shared : 0
|
||||
const totalNumber = databaseCount + logsResultCount
|
||||
const isLoadingCounts = isLoadingCount || (canShowLogsSection && isLoadingLogsCount)
|
||||
const hasCounts = count !== undefined || (canShowLogsSection && logsCount !== undefined)
|
||||
|
||||
const {
|
||||
data: logsData,
|
||||
isPending: isLoadingLogs,
|
||||
hasNextPage: hasNextLogsPage,
|
||||
fetchNextPage: fetchNextLogsPage,
|
||||
isFetchingNextPage: isFetchingNextLogsPage,
|
||||
} = useSqlSnippetsQuery(
|
||||
{ projectRef, type: 'log_sql', name: searchName },
|
||||
{ enabled: canShowLogsSection, placeholderData: keepPreviousData }
|
||||
)
|
||||
|
||||
const databaseTree = useSnippetSearchTree(data?.pages)
|
||||
const logsTree = useSnippetSearchTree(logsData?.pages)
|
||||
const hasDatabaseResults = databaseTree.count > 0
|
||||
const hasLogsResults = logsTree.count > 0
|
||||
// Label the groups only when both are present; a single group needs no heading.
|
||||
const showGroupHeadings = hasDatabaseResults && hasLogsResults
|
||||
// The results body is loading until both source queries (when enabled) settle.
|
||||
const isSearchLoading = isLoading || (canShowLogsSection && isLoadingLogs)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col grow">
|
||||
{isLoadingCount ? (
|
||||
{isLoadingCounts && (
|
||||
<div className="px-4 py-1 pb-2.5">
|
||||
<Loader2 className="animate-spin" size={14} />
|
||||
</div>
|
||||
) : !!count ? (
|
||||
)}
|
||||
{!isLoadingCounts && hasCounts && (
|
||||
<p className="px-4 pb-2 text-sm text-foreground-lighter">
|
||||
{totalNumber} result{totalNumber > 1 ? 's' : ''} found
|
||||
{totalNumber} result{totalNumber === 1 ? '' : 's'} found
|
||||
</p>
|
||||
) : null}
|
||||
{isLoading ? (
|
||||
)}
|
||||
{isSearchLoading && (
|
||||
<div className="px-4 flex flex-col gap-y-1">
|
||||
<ShimmeringLoader className="py-2.5" />
|
||||
<ShimmeringLoader className="py-2.5 w-5/6" />
|
||||
<ShimmeringLoader className="py-2.5 w-3/4" />
|
||||
</div>
|
||||
) : (
|
||||
<TreeView
|
||||
multiSelect
|
||||
togglableSelect
|
||||
clickAction="EXCLUSIVE_SELECT"
|
||||
data={treeState}
|
||||
aria-label="private-snippets"
|
||||
nodeRenderer={({ element, ...props }) => {
|
||||
const isOpened = 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 = tabs.previewTabId === tabId
|
||||
const isActive = !isPreview && element.metadata?.id === id
|
||||
const visibility =
|
||||
element.metadata?.visibility === 'user'
|
||||
? 'Private'
|
||||
: element.metadata?.visibility === 'project'
|
||||
? 'Shared'
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<SQLEditorTreeViewItem
|
||||
{...props}
|
||||
element={{
|
||||
...element,
|
||||
name: (
|
||||
<span className="flex flex-col py-0.5">
|
||||
<span className="truncate">{element.name}</span>
|
||||
{!!visibility && (
|
||||
<span className="text-foreground-lighter text-xs">{visibility}</span>
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
}}
|
||||
nameForTitle={element.name}
|
||||
isBranch={false}
|
||||
isOpened={isOpened && !isPreview}
|
||||
isSelected={isActive}
|
||||
isPreview={isPreview}
|
||||
isLastItem={snippetsLastItemIds.has(element.id as string)}
|
||||
status="idle"
|
||||
className="items-start h-[40px] [&>svg]:translate-y-0.5"
|
||||
onSelectDelete={() => setSelectedSnippetToDelete(element.metadata as Snippet)}
|
||||
onSelectRename={() => setSelectedSnippetToRename(element.metadata as Snippet)}
|
||||
onSelectDownload={() => setSelectedSnippetToDownload(element.metadata as Snippet)}
|
||||
onSelectShare={() => setSelectedSnippetToShare(element.metadata as Snippet)}
|
||||
onSelectUnshare={() => setSelectedSnippetToUnshare(element.metadata as Snippet)}
|
||||
hasNextPage={hasNextPage}
|
||||
fetchNextPage={fetchNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
onDoubleClick={(e) => {
|
||||
e.preventDefault()
|
||||
tabs.makeTabPermanent(tabId)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!isSearchLoading && !hasDatabaseResults && !hasLogsResults && (
|
||||
<p className="px-4 text-sm text-foreground-lighter">No queries found</p>
|
||||
)}
|
||||
{!isSearchLoading && hasDatabaseResults && (
|
||||
<>
|
||||
{showGroupHeadings && <SearchGroupHeading>Database</SearchGroupHeading>}
|
||||
<SqlSnippetTree
|
||||
ariaLabel="database-snippets"
|
||||
data={databaseTree.treeState}
|
||||
lastItemIds={databaseTree.lastItemIds}
|
||||
showVisibility
|
||||
itemClassName="items-start h-[40px] [&>svg]:translate-y-0.5"
|
||||
hasNextPage={hasNextPage}
|
||||
fetchNextPage={fetchNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
onSelectDelete={setSelectedSnippetToDelete}
|
||||
onSelectRename={setSelectedSnippetToRename}
|
||||
onSelectDownload={setSelectedSnippetToDownload}
|
||||
onSelectShare={setSelectedSnippetToShare}
|
||||
onSelectUnshare={setSelectedSnippetToUnshare}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{!isSearchLoading && canShowLogsSection && hasLogsResults && (
|
||||
<>
|
||||
{showGroupHeadings && <SearchGroupHeading>Logs</SearchGroupHeading>}
|
||||
<SqlSnippetTree
|
||||
ariaLabel="logs-snippets"
|
||||
data={logsTree.treeState}
|
||||
lastItemIds={logsTree.lastItemIds}
|
||||
showVisibility
|
||||
itemClassName="items-start h-[40px] [&>svg]:translate-y-0.5"
|
||||
hasNextPage={hasNextLogsPage}
|
||||
fetchNextPage={fetchNextLogsPage}
|
||||
isFetchingNextPage={isFetchingNextLogsPage}
|
||||
onSelectDelete={setSelectedSnippetToDelete}
|
||||
onSelectRename={setSelectedSnippetToRename}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { useParams } from 'common'
|
||||
import { TreeView } from 'ui'
|
||||
|
||||
import type { TreeViewItemProps } from './SQLEditorNav.utils'
|
||||
import { SQLEditorTreeViewItem } from './SQLEditorTreeViewItem'
|
||||
import { getSnippetSource } from '@/components/interfaces/SQLEditor/querySource'
|
||||
import { Snippet } from '@/data/content/sql-folders-query'
|
||||
import { createTabId, useTabsStateSnapshot } from '@/state/tabs'
|
||||
|
||||
interface SqlSnippetTreeProps {
|
||||
ariaLabel: string
|
||||
data: TreeViewItemProps[]
|
||||
lastItemIds: Set<string>
|
||||
/** Additional (multi-)selected snippet ids to highlight beyond the active one. */
|
||||
selectedSnippetIds?: string[]
|
||||
/** Search results show a source/visibility sublabel under each snippet name. */
|
||||
showVisibility?: boolean
|
||||
itemClassName?: string
|
||||
hasNextPage?: boolean
|
||||
fetchNextPage?: () => void
|
||||
isFetchingNextPage?: boolean
|
||||
onSelectDelete?: (snippet: Snippet) => void
|
||||
onSelectRename?: (snippet: Snippet) => void
|
||||
onSelectDownload?: (snippet: Snippet) => void
|
||||
onSelectShare?: (snippet: Snippet) => void
|
||||
onSelectUnshare?: (snippet: Snippet) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* The sublabel under a snippet's name. Logs snippets are labeled by source rather
|
||||
* than visibility: they have no share action, so "Private" says nothing that
|
||||
* distinguishes them from the database query sitting right above them in the
|
||||
* same result list, while "Logs" does.
|
||||
*/
|
||||
const snippetSublabel = (snippet: Snippet) => {
|
||||
if (getSnippetSource(snippet) === 'logs') return 'Logs'
|
||||
if (snippet.visibility === 'user') return 'Private'
|
||||
if (snippet.visibility === 'project') return 'Shared'
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const SqlSnippetTree = ({
|
||||
ariaLabel,
|
||||
data,
|
||||
lastItemIds,
|
||||
selectedSnippetIds,
|
||||
showVisibility = false,
|
||||
itemClassName,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
isFetchingNextPage,
|
||||
onSelectDelete,
|
||||
onSelectRename,
|
||||
onSelectDownload,
|
||||
onSelectShare,
|
||||
onSelectUnshare,
|
||||
}: SqlSnippetTreeProps) => {
|
||||
const { id } = useParams()
|
||||
const tabs = useTabsStateSnapshot()
|
||||
|
||||
return (
|
||||
<TreeView
|
||||
multiSelect
|
||||
togglableSelect
|
||||
clickAction="EXCLUSIVE_SELECT"
|
||||
data={data}
|
||||
aria-label={ariaLabel}
|
||||
nodeRenderer={({ element, ...props }) => {
|
||||
const snippet = element.metadata as Snippet
|
||||
const isOpened = Object.values(tabs.tabsMap).some(
|
||||
(tab) => tab.metadata?.sqlId === snippet.id
|
||||
)
|
||||
const tabId = createTabId('sql', { id: snippet.id })
|
||||
const isPreview = tabs.previewTabId === tabId
|
||||
const isActive = !isPreview && snippet.id === id
|
||||
const isSelected = isActive || (selectedSnippetIds?.includes(snippet.id) ?? false)
|
||||
const sublabel = showVisibility ? snippetSublabel(snippet) : undefined
|
||||
|
||||
return (
|
||||
<SQLEditorTreeViewItem
|
||||
{...props}
|
||||
element={
|
||||
showVisibility
|
||||
? {
|
||||
...element,
|
||||
name: (
|
||||
<span className="flex flex-col py-0.5">
|
||||
<span className="truncate">{element.name}</span>
|
||||
{!!sublabel && (
|
||||
<span className="text-foreground-lighter text-xs">{sublabel}</span>
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
}
|
||||
: element
|
||||
}
|
||||
nameForTitle={showVisibility ? (element.name as string) : undefined}
|
||||
isBranch={false}
|
||||
isOpened={isOpened && !isPreview}
|
||||
isSelected={isSelected}
|
||||
isPreview={isPreview}
|
||||
isLastItem={lastItemIds.has(element.id as string)}
|
||||
status="idle"
|
||||
className={itemClassName}
|
||||
onSelectDelete={onSelectDelete ? () => onSelectDelete(snippet) : undefined}
|
||||
onSelectRename={onSelectRename ? () => onSelectRename(snippet) : undefined}
|
||||
onSelectDownload={onSelectDownload ? () => onSelectDownload(snippet) : undefined}
|
||||
onSelectShare={onSelectShare ? () => onSelectShare(snippet) : undefined}
|
||||
onSelectUnshare={onSelectUnshare ? () => onSelectUnshare(snippet) : undefined}
|
||||
hasNextPage={hasNextPage}
|
||||
fetchNextPage={fetchNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
onDoubleClick={(e) => {
|
||||
e.preventDefault()
|
||||
tabs.makeTabPermanent(tabId)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -71,7 +71,7 @@ export function RecentItems() {
|
||||
className="flex items-center gap-4 rounded-lg bg-surface-100 py-2 transition-colors hover:bg-surface-200"
|
||||
>
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-sm bg-surface-100 border">
|
||||
<EntityTypeIcon type={item.type} />
|
||||
<EntityTypeIcon type={item.type} sqlSource={item.metadata?.sqlSource} />
|
||||
</div>
|
||||
<div className="flex flex-1 gap-5 items-center">
|
||||
<span className="text-sm text-foreground">
|
||||
|
||||
@@ -104,7 +104,7 @@ export const SortableTab = ({
|
||||
)}
|
||||
{...listeners}
|
||||
>
|
||||
<EntityTypeIcon type={tab.type} />
|
||||
<EntityTypeIcon type={tab.type} sqlSource={tab.metadata?.sqlSource} />
|
||||
<div className="flex items-center gap-0">
|
||||
<AnimatePresence mode="popLayout" initial>
|
||||
{shouldShowSchema && (
|
||||
|
||||
@@ -17,7 +17,7 @@ export const TabPreview = ({ tab }: { tab: string }) => {
|
||||
animate={{ opacity: 0.7 }}
|
||||
className="flex relative items-center gap-2 px-3 text-xs bg-dash-sidebar dark:bg-surface-100 shadow-lg rounded-xs h-10"
|
||||
>
|
||||
<EntityTypeIcon type={tabData.type} />
|
||||
<EntityTypeIcon type={tabData.type} sqlSource={tabData.metadata?.sqlSource} />
|
||||
<span>{tabData.label || 'Untitled'}</span>
|
||||
<div className="absolute w-full top-0 left-0 right-0 h-px bg-foreground-muted" />
|
||||
</motion.div>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { useSqlEditorTabsCleanup } from './Tabs.utils'
|
||||
import { createTabsState, TabsStateContext, type Tab } from '@/state/tabs'
|
||||
|
||||
const dbTab = (id: string): Tab => ({
|
||||
id: `sql-${id}`,
|
||||
type: 'sql',
|
||||
label: id,
|
||||
isPreview: false,
|
||||
metadata: { sqlId: id, sqlSource: 'database' },
|
||||
})
|
||||
|
||||
const logsTab = (id: string): Tab => ({
|
||||
id: `sql-${id}`,
|
||||
type: 'sql',
|
||||
label: id,
|
||||
isPreview: false,
|
||||
metadata: { sqlId: id, sqlSource: 'logs' },
|
||||
})
|
||||
|
||||
function renderCleanup(store: ReturnType<typeof createTabsState>) {
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<TabsStateContext.Provider value={store}>{children}</TabsStateContext.Provider>
|
||||
)
|
||||
return renderHook(() => useSqlEditorTabsCleanup(), { wrapper }).result.current
|
||||
}
|
||||
|
||||
describe('useSqlEditorTabsCleanup', () => {
|
||||
it('prunes tabs for deleted snippets (database and logs) while keeping live ones', () => {
|
||||
const store = createTabsState('default')
|
||||
store.addTab(dbTab('db-stale'))
|
||||
store.addTab(logsTab('logs-stale'))
|
||||
store.addTab(logsTab('logs-live'))
|
||||
|
||||
const cleanup = renderCleanup(store)
|
||||
|
||||
// The caller passes both `sql` and `log_sql` snippets it knows to be live; only
|
||||
// `logs-live` is present, so the two stale tabs are pruned and it is kept.
|
||||
act(() => cleanup({ snippets: [{ id: 'logs-live', type: 'log_sql', name: 'logs-live' }] }))
|
||||
|
||||
expect(store.openTabs).toEqual(['sql-logs-live'])
|
||||
expect(store.tabsMap['sql-db-stale']).toBeUndefined()
|
||||
expect(store.tabsMap['sql-logs-stale']).toBeUndefined()
|
||||
expect(store.tabsMap['sql-logs-live']).toBeDefined()
|
||||
})
|
||||
|
||||
it('keeps a logs tab that still exists in the snippet list', () => {
|
||||
const store = createTabsState('default')
|
||||
store.addTab(logsTab('logs'))
|
||||
|
||||
const cleanup = renderCleanup(store)
|
||||
act(() => cleanup({ snippets: [{ id: 'logs', type: 'log_sql', name: 'logs' }] }))
|
||||
|
||||
expect(store.openTabs).toEqual(['sql-logs'])
|
||||
})
|
||||
|
||||
it('preserves logs tabs when logs snippets are not authoritative (canPruneLogsTabs false)', () => {
|
||||
const store = createTabsState('default')
|
||||
store.addTab(dbTab('db-stale'))
|
||||
store.addTab(logsTab('logs'))
|
||||
|
||||
const cleanup = renderCleanup(store)
|
||||
// Logs section disabled / query errored: no logs snippets passed, and logs tabs
|
||||
// must not be pruned — but stale database tabs still are.
|
||||
act(() => cleanup({ snippets: [], canPruneLogsTabs: false }))
|
||||
|
||||
expect(store.openTabs).toEqual(['sql-logs'])
|
||||
expect(store.tabsMap['sql-db-stale']).toBeUndefined()
|
||||
|
||||
// Recent items carry their own logs-source condition, so assert them separately.
|
||||
const recentIds = store.recentItems.map((item) => item.id)
|
||||
expect(recentIds).toContain('sql-logs')
|
||||
expect(recentIds).not.toContain('sql-db-stale')
|
||||
})
|
||||
|
||||
it('prunes recent items for deleted logs snippets while keeping live ones', () => {
|
||||
const store = createTabsState('default')
|
||||
store.addTab(logsTab('logs-stale'))
|
||||
store.addTab(logsTab('logs-live'))
|
||||
|
||||
const cleanup = renderCleanup(store)
|
||||
act(() => cleanup({ snippets: [{ id: 'logs-live', type: 'log_sql', name: 'logs-live' }] }))
|
||||
|
||||
const recentIds = store.recentItems.map((item) => item.id)
|
||||
expect(recentIds).toContain('sql-logs-live')
|
||||
expect(recentIds).not.toContain('sql-logs-stale')
|
||||
})
|
||||
})
|
||||
@@ -65,46 +65,69 @@ export function useSqlEditorTabsCleanup() {
|
||||
const tabMapRef = useLatest(tabs.tabsMap)
|
||||
const openTabsRef = useLatest(tabs.openTabs)
|
||||
|
||||
return useCallback(({ snippets }: { snippets: { id: string; type: string; name: 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']
|
||||
return useCallback(
|
||||
({
|
||||
snippets,
|
||||
canPruneLogsTabs = true,
|
||||
}: {
|
||||
snippets: { id: string; type: string; name: string }[]
|
||||
// Whether `log_sql` snippets in `snippets` are authoritative. When false (the
|
||||
// logs section is disabled or its query errored) we can't know which logs
|
||||
// snippets exist, so logs tabs are preserved rather than pruned as stale.
|
||||
canPruneLogsTabs?: boolean
|
||||
}) => {
|
||||
// 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,
|
||||
]
|
||||
// Identify all SQL snippets / content by their tab ids. Both database (`sql`) and
|
||||
// logs (`log_sql`) snippets live in the `sql-` tab id space, so both are counted as
|
||||
// live. Anything not in this set is treated as removed outside the session and pruned.
|
||||
const currentContentIds = [
|
||||
...snippets
|
||||
.filter((content) => content.type === 'sql' || content.type === 'log_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 snippetTabsToBeCleaned = openTabsRef.current.filter(
|
||||
(id: string) => id.startsWith('sql') && !currentContentIds.includes(id)
|
||||
)
|
||||
tabs.removeTabs(snippetTabsToBeCleaned)
|
||||
const isPrunable = (id: string) =>
|
||||
id.startsWith('sql') &&
|
||||
!currentContentIds.includes(id) &&
|
||||
(canPruneLogsTabs || tabMapRef.current[id]?.metadata?.sqlSource !== 'logs')
|
||||
|
||||
// Remove any recent items that might no longer be existing (removed outside of the dashboard session)
|
||||
const recentItems = tabs.getRecentItemsByType('sql')
|
||||
tabs.removeRecentItems(
|
||||
recentItems
|
||||
? recentItems.filter((item) => !currentContentIds.includes(item.id)).map((item) => item.id)
|
||||
: []
|
||||
)
|
||||
// Remove any snippet tabs that might no longer be existing (removed outside of the dashboard session)
|
||||
const snippetTabsToBeCleaned = openTabsRef.current.filter(isPrunable)
|
||||
tabs.removeTabs(snippetTabsToBeCleaned)
|
||||
|
||||
// [Joshen] Validate for opened tabs, if their label matches the snippet's name - update label if not
|
||||
// As the snippets name could've been updated outside of the SQL Editor session
|
||||
// e.g for a shared snippet, the owner could've updated the name of the snippet
|
||||
const openSqlTabs = openTabsRef.current
|
||||
.map((id) => tabMapRef.current[id])
|
||||
.filter((tab) => !!tab && editorEntityTypes['sql']?.includes(tab.type))
|
||||
// Remove any recent items that might no longer be existing (removed outside of the dashboard session)
|
||||
const recentItems = tabs.getRecentItemsByType('sql')
|
||||
tabs.removeRecentItems(
|
||||
recentItems
|
||||
? recentItems
|
||||
.filter(
|
||||
(item) =>
|
||||
!currentContentIds.includes(item.id) &&
|
||||
(canPruneLogsTabs || item.metadata?.sqlSource !== 'logs')
|
||||
)
|
||||
.map((item) => item.id)
|
||||
: []
|
||||
)
|
||||
|
||||
openSqlTabs.forEach((tab) => {
|
||||
const snippet = snippets?.find((x) => tab.metadata?.sqlId === x.id)
|
||||
if (!!snippet && snippet.name !== tab.label) tabs.updateTab(tab.id, { label: snippet.name })
|
||||
})
|
||||
}, [])
|
||||
// [Joshen] Validate for opened tabs, if their label matches the snippet's name - update label if not
|
||||
// As the snippets name could've been updated outside of the SQL Editor session
|
||||
// e.g for a shared snippet, the owner could've updated the name of the snippet
|
||||
const openSqlTabs = openTabsRef.current
|
||||
.map((id) => tabMapRef.current[id])
|
||||
.filter((tab) => !!tab && editorEntityTypes['sql']?.includes(tab.type))
|
||||
|
||||
openSqlTabs.forEach((tab) => {
|
||||
const snippet = snippets?.find((x) => tab.metadata?.sqlId === x.id)
|
||||
if (!!snippet && snippet.name !== tab.label) tabs.updateTab(tab.id, { label: snippet.name })
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
}
|
||||
|
||||
interface UseTabsScrollOptions {
|
||||
|
||||
@@ -1,13 +1,36 @@
|
||||
import { Eye, GitBranch, Table2 } from 'lucide-react'
|
||||
import { Eye, GitBranch, ScrollText, Table2 } from 'lucide-react'
|
||||
import { cn, SQL_ICON } from 'ui'
|
||||
|
||||
import type { SqlSnippetSource } from '@/components/interfaces/SQLEditor/querySource'
|
||||
import { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants'
|
||||
|
||||
/**
|
||||
* The single icon representing a logs (`log_sql`) snippet — reused by the tabs
|
||||
* (via EntityTypeIcon) and the nav tree so the two can't drift. Callers pass the
|
||||
* context-appropriate size/className; the icon and stroke stay the same.
|
||||
*/
|
||||
export const LogsSnippetIcon = ({
|
||||
size = 15,
|
||||
strokeWidth = 1.5,
|
||||
className,
|
||||
}: {
|
||||
size?: number
|
||||
strokeWidth?: number
|
||||
className?: string
|
||||
}) => (
|
||||
<ScrollText
|
||||
size={size}
|
||||
strokeWidth={strokeWidth}
|
||||
className={cn('transition-colors', className)}
|
||||
/>
|
||||
)
|
||||
|
||||
interface EntityTypeIconProps {
|
||||
type: 'sql' | 'schema' | 'new' | 'r' | 'v' | 'm' | 'f' | 'p'
|
||||
size?: number
|
||||
strokeWidth?: number
|
||||
isActive?: boolean
|
||||
sqlSource?: SqlSnippetSource
|
||||
}
|
||||
|
||||
export const EntityTypeIcon = ({
|
||||
@@ -15,7 +38,23 @@ export const EntityTypeIcon = ({
|
||||
size = 15,
|
||||
strokeWidth = 1.5,
|
||||
isActive,
|
||||
sqlSource,
|
||||
}: EntityTypeIconProps) => {
|
||||
if (type === 'sql' && sqlSource === 'logs') {
|
||||
return (
|
||||
<LogsSnippetIcon
|
||||
size={size}
|
||||
strokeWidth={strokeWidth}
|
||||
className={cn(
|
||||
'text-foreground-muted',
|
||||
'group-aria-selected:text-foreground',
|
||||
'w-4 h-4',
|
||||
'-ml-0.5'
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === 'sql') {
|
||||
return (
|
||||
<SQL_ICON
|
||||
|
||||
@@ -16,6 +16,7 @@ export const contentKeys = {
|
||||
sqlSnippets: (
|
||||
projectRef: string | undefined,
|
||||
options?: {
|
||||
type?: 'sql' | 'log_sql'
|
||||
sort?: 'inserted_at' | 'name'
|
||||
name?: string
|
||||
visibility?: string
|
||||
|
||||
@@ -9,9 +9,14 @@ import type { UseCustomInfiniteQueryOptions } from '@/types'
|
||||
|
||||
export type SqlSnippet = Extract<Content, { type: 'sql' }>
|
||||
|
||||
/** The snippet content types this query can list. Defaults to `'sql'`; the nav's
|
||||
* Logs section passes `'log_sql'` to list logs snippets through the same shape. */
|
||||
type SqlSnippetType = 'sql' | 'log_sql'
|
||||
|
||||
interface GetSqlSnippetsVariables {
|
||||
projectRef?: string
|
||||
cursor?: string
|
||||
type?: SqlSnippetType
|
||||
visibility?: SqlSnippet['visibility']
|
||||
favorite?: boolean
|
||||
name?: string
|
||||
@@ -19,7 +24,7 @@ interface GetSqlSnippetsVariables {
|
||||
}
|
||||
|
||||
export async function getSqlSnippets(
|
||||
{ projectRef, cursor, visibility, favorite, name, sort }: GetSqlSnippetsVariables,
|
||||
{ projectRef, cursor, type = 'sql', visibility, favorite, name, sort }: GetSqlSnippetsVariables,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
if (typeof projectRef === 'undefined') {
|
||||
@@ -32,7 +37,7 @@ export async function getSqlSnippets(
|
||||
params: {
|
||||
path: { ref: projectRef },
|
||||
query: {
|
||||
type: 'sql',
|
||||
type,
|
||||
cursor,
|
||||
visibility,
|
||||
favorite,
|
||||
@@ -60,7 +65,14 @@ export type SqlSnippetsData = Awaited<ReturnType<typeof getSqlSnippets>>
|
||||
export type SqlSnippetsError = unknown
|
||||
|
||||
export const useSqlSnippetsQuery = <TData = SqlSnippetsData>(
|
||||
{ projectRef, sort, name, visibility, favorite }: Omit<GetSqlSnippetsVariables, 'cursor'>,
|
||||
{
|
||||
projectRef,
|
||||
type = 'sql',
|
||||
sort,
|
||||
name,
|
||||
visibility,
|
||||
favorite,
|
||||
}: Omit<GetSqlSnippetsVariables, 'cursor'>,
|
||||
{
|
||||
enabled = true,
|
||||
...options
|
||||
@@ -73,9 +85,9 @@ export const useSqlSnippetsQuery = <TData = SqlSnippetsData>(
|
||||
> = {}
|
||||
) =>
|
||||
useInfiniteQuery({
|
||||
queryKey: contentKeys.sqlSnippets(projectRef, { sort, name, visibility, favorite }),
|
||||
queryKey: contentKeys.sqlSnippets(projectRef, { type, sort, name, visibility, favorite }),
|
||||
queryFn: ({ signal, pageParam: cursor }) =>
|
||||
getSqlSnippets({ projectRef, cursor, sort, name, visibility, favorite }, signal),
|
||||
getSqlSnippets({ projectRef, cursor, type, sort, name, visibility, favorite }, signal),
|
||||
enabled: enabled && typeof projectRef !== 'undefined',
|
||||
initialPageParam: undefined,
|
||||
getNextPageParam(lastPage) {
|
||||
|
||||
@@ -2,11 +2,12 @@ import { usePrevious } from '@uidotdev/usehooks'
|
||||
import { useParams } from 'common/hooks/useParams'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useEffectEvent } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from 'ui'
|
||||
import { Admonition } from 'ui-patterns/Admonition'
|
||||
|
||||
import { getSnippetSource } from '@/components/interfaces/SQLEditor/querySource'
|
||||
import { SQLEditor } from '@/components/interfaces/SQLEditor/SQLEditor'
|
||||
import { generateSnippetTitle } from '@/components/interfaces/SQLEditor/SQLEditor.constants'
|
||||
import { DefaultLayout } from '@/components/layouts/DefaultLayout'
|
||||
@@ -107,11 +108,33 @@ const SqlEditor: NextPageWithLayout = () => {
|
||||
metadata: {
|
||||
sqlId: id,
|
||||
name: snippet?.name,
|
||||
// The snippet may not be loaded yet at tab-creation time; the effect
|
||||
// below backfills the source once it is. Source is immutable, so once
|
||||
// set it never needs updating again.
|
||||
...(snippet !== undefined && { sqlSource: getSnippetSource(snippet) }),
|
||||
},
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [router.isReady, id])
|
||||
|
||||
// Backfill the tab's source once the snippet loads. Covers both a freshly
|
||||
// created tab whose snippet arrived after creation and tabs persisted before
|
||||
// `sqlSource` existed (absent → filled from the loaded snippet type). Reads the
|
||||
// latest tabs snapshot via useEffectEvent so the effect only re-runs on id/snippet.
|
||||
const backfillTabSource = useEffectEvent(() => {
|
||||
if (!id || id === 'new' || snippet === undefined) return
|
||||
|
||||
const tabId = createTabId('sql', { id })
|
||||
const tab = tabs.tabsMap[tabId]
|
||||
if (tab !== undefined && tab.metadata?.sqlSource === undefined) {
|
||||
tabs.updateTab(tabId, { sqlSource: getSnippetSource(snippet) })
|
||||
}
|
||||
})
|
||||
useEffect(() => {
|
||||
backfillTabSource()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- useEffectEvent fn intentionally not a dep (eslint-plugin-react-hooks v5 doesn't correctly ignore useEffectEvent yet)
|
||||
}, [id, snippet])
|
||||
|
||||
// The snippet no longer exists (e.g. deleted from another tab or session): clean up
|
||||
// any stale tab and dashboard history references so navigation doesn't resurrect it,
|
||||
// then fall back to a new snippet instead of rendering a dead state
|
||||
|
||||
@@ -72,6 +72,46 @@ describe('tabs recent items', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('tabs sql source metadata', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('backfills sqlSource onto a tab and its recent item', () => {
|
||||
const store = createTabsState('default')
|
||||
|
||||
store.addTab({
|
||||
id: 'sql-a',
|
||||
type: 'sql',
|
||||
label: 'Logs query',
|
||||
metadata: { sqlId: 'a', name: 'Logs query' },
|
||||
isPreview: false,
|
||||
})
|
||||
|
||||
// Persisted before the field existed → absent until backfilled from the snippet.
|
||||
expect(store.tabsMap['sql-a'].metadata?.sqlSource).toBeUndefined()
|
||||
|
||||
store.updateTab('sql-a', { sqlSource: 'logs' })
|
||||
|
||||
expect(store.tabsMap['sql-a'].metadata?.sqlSource).toBe('logs')
|
||||
expect(store.recentItems[0].metadata?.sqlSource).toBe('logs')
|
||||
})
|
||||
|
||||
it('carries sqlSource from a tab into its recent item on creation', () => {
|
||||
const store = createTabsState('default')
|
||||
|
||||
store.addTab({
|
||||
id: 'sql-a',
|
||||
type: 'sql',
|
||||
label: 'Logs query',
|
||||
metadata: { sqlId: 'a', name: 'Logs query', sqlSource: 'logs' },
|
||||
isPreview: false,
|
||||
})
|
||||
|
||||
expect(store.recentItems[0].metadata?.sqlSource).toBe('logs')
|
||||
})
|
||||
})
|
||||
|
||||
describe('tabs removal', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
|
||||
+37
-14
@@ -12,6 +12,7 @@ import {
|
||||
import { proxy, subscribe, useSnapshot } from 'valtio'
|
||||
|
||||
import { buildTableEditorUrl } from '@/components/grid/SupabaseGrid.utils'
|
||||
import type { SqlSnippetSource } from '@/components/interfaces/SQLEditor/querySource'
|
||||
import { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants'
|
||||
|
||||
export const editorEntityTypes = {
|
||||
@@ -44,6 +45,14 @@ export interface Tab {
|
||||
tableId?: number
|
||||
sqlId?: string
|
||||
scrollTop?: number
|
||||
/**
|
||||
* For SQL tabs, which backend the snippet queries (`'database'` | `'logs'`),
|
||||
* so the tab can show the matching icon without re-fetching the snippet.
|
||||
* Absent on tabs persisted before this field existed — treat absent as
|
||||
* `'database'` and backfill from the loaded snippet (source is immutable, so
|
||||
* it never goes stale once set).
|
||||
*/
|
||||
sqlSource?: SqlSnippetSource
|
||||
}
|
||||
isPreview?: boolean
|
||||
createdAt?: Date
|
||||
@@ -98,6 +107,7 @@ export interface RecentItem {
|
||||
name?: string
|
||||
tableId?: number
|
||||
sqlId?: string
|
||||
sqlSource?: SqlSnippetSource
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,22 +280,35 @@ export function createTabsState(projectRef: string) {
|
||||
store.previewTabId = tab.id
|
||||
store.activeTab = tab.id
|
||||
},
|
||||
updateTab: (id: string, updates: { label?: string; scrollTop?: number }) => {
|
||||
if (!!store.tabsMap[id]) {
|
||||
if ('label' in updates) {
|
||||
store.tabsMap[id].label = updates.label
|
||||
// Keep the persisted name aligned with the visible label so browser titles
|
||||
// and tab state recover cleanly after entity renames.
|
||||
if (typeof updates.label === 'string' && store.tabsMap[id].metadata) {
|
||||
store.tabsMap[id].metadata.name = updates.label
|
||||
}
|
||||
updateTab: (
|
||||
id: string,
|
||||
updates: { label?: string; scrollTop?: number; sqlSource?: SqlSnippetSource }
|
||||
) => {
|
||||
const tab = store.tabsMap[id]
|
||||
if (!tab) return
|
||||
|
||||
const recentItem = store.recentItems.find((item) => item.id === id)
|
||||
if (recentItem) syncRecentItemWithTab(recentItem, store.tabsMap[id])
|
||||
}
|
||||
if ('scrollTop' in updates && store.tabsMap[id].metadata) {
|
||||
store.tabsMap[id].metadata.scrollTop = updates.scrollTop
|
||||
if ('label' in updates) {
|
||||
tab.label = updates.label
|
||||
// Keep the persisted name aligned with the visible label so browser titles
|
||||
// and tab state recover cleanly after entity renames.
|
||||
if (typeof updates.label === 'string' && tab.metadata) {
|
||||
tab.metadata.name = updates.label
|
||||
}
|
||||
|
||||
const recentItem = store.recentItems.find((item) => item.id === id)
|
||||
if (recentItem) syncRecentItemWithTab(recentItem, tab)
|
||||
}
|
||||
if ('scrollTop' in updates && tab.metadata) {
|
||||
tab.metadata.scrollTop = updates.scrollTop
|
||||
}
|
||||
// Backfill the immutable source onto a tab (and its recent item) that
|
||||
// predates the field, so its icon resolves correctly once the snippet loads.
|
||||
if (updates.sqlSource !== undefined) {
|
||||
if (tab.metadata) tab.metadata.sqlSource = updates.sqlSource
|
||||
else tab.metadata = { sqlSource: updates.sqlSource }
|
||||
|
||||
const recentItem = store.recentItems.find((item) => item.id === id)
|
||||
if (recentItem) syncRecentItemWithTab(recentItem, tab)
|
||||
}
|
||||
},
|
||||
// Function to remove a tab from the store
|
||||
|
||||
Reference in new issue
Block a user