Refactor search list in sql editor to have context menu (#37945)

* Refactor search list in sql editor to have context menu

* Add visibility to snippet item in search list tree view

* Address feedback

* Attempt to fix unit test for edit secret modal

* REvert changes to edit secret modal test
This commit is contained in:
Joshen Lim
2025-08-18 10:26:33 +07:00
committed by GitHub
parent b1261751e8
commit c153df20c8
10 changed files with 515 additions and 268 deletions

View File

@@ -1,15 +1,16 @@
import { useState } from 'react'
import { describe, expect, it, beforeEach, vi } from 'vitest'
import { mockAnimationsApi } from 'jsdom-testing-mocks'
import { screen, waitFor, fireEvent } from '@testing-library/dom'
import { fireEvent, screen, waitFor } from '@testing-library/dom'
import userEvent from '@testing-library/user-event'
import { ProjectContextProvider } from 'components/layouts/ProjectLayout/ProjectContext'
import { mockAnimationsApi } from 'jsdom-testing-mocks'
import { useState } from 'react'
import { render } from 'tests/helpers'
import { addAPIMock } from 'tests/lib/msw'
import { ProjectContextProvider } from 'components/layouts/ProjectLayout/ProjectContext'
import EditSecretModal from '../EditSecretModal'
import { routerMock } from 'tests/lib/route-mock'
import { VaultSecret } from 'types'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import EditSecretModal from '../EditSecretModal'
const secret = {
const secret: VaultSecret = {
id: '47ca58b4-01c5-4a71-8814-c73856b02e0e',
name: 'test',
description: 'new text',

View File

@@ -0,0 +1,98 @@
import { useRouter } from 'next/router'
import { toast } from 'sonner'
import { useParams } from 'common'
import { useContentDeleteMutation } from 'data/content/content-delete-mutation'
import { Snippet } from 'data/content/sql-folders-query'
import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2'
import { createTabId, useTabsStateSnapshot } from 'state/tabs'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
export const DeleteSnippetsModal = ({
snippets,
visible,
onClose,
}: {
visible: boolean
snippets: Snippet[]
onClose: () => void
}) => {
const router = useRouter()
const { ref: projectRef, id } = useParams()
const tabs = useTabsStateSnapshot()
const snapV2 = useSqlEditorV2StateSnapshot()
const postDeleteCleanup = (ids: string[]) => {
if (!!id && ids.includes(id)) {
const openedSQLTabs = tabs.openTabs.filter((x) => x.startsWith('sql-') && !x.includes(id))
if (openedSQLTabs.length > 0) {
// [Joshen] For simplicity, just opening the first tab for now
const firstTabId = openedSQLTabs[0].split('sql-')[1]
router.push(`/project/${projectRef}/sql/${firstTabId}`)
} else {
router.push(`/project/${projectRef}/sql/new`)
}
}
if (ids.length > 0) ids.forEach((id) => snapV2.removeSnippet(id))
}
const { mutate: deleteContent, isLoading: isDeleting } = useContentDeleteMutation({
onSuccess: (data) => {
toast.success(
`Successfully deleted ${snippets.length.toLocaleString()} quer${snippets.length > 1 ? 'ies' : 'y'}`
)
// 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 }))
tabs.removeTabs(tabIds)
postDeleteCleanup(data)
onClose()
},
onError: (error, data) => {
if (error.message.includes('Contents not found')) {
postDeleteCleanup(data.ids)
onClose()
} else {
toast.error(`Failed to delete query: ${error.message}`)
}
},
})
const onConfirmDelete = () => {
if (!projectRef) return console.error('Project ref is required')
deleteContent({ projectRef, ids: snippets.map((x) => x.id) })
}
return (
<ConfirmationModal
size="small"
visible={visible}
title={`Confirm to delete ${snippets.length === 1 ? 'query' : `${snippets.length.toLocaleString()} quer${snippets.length > 1 ? 'ies' : 'y'}`}`}
confirmLabel={`Delete ${snippets.length.toLocaleString()} quer${snippets.length > 1 ? 'ies' : 'y'}`}
confirmLabelLoading="Deleting query"
loading={isDeleting}
variant="destructive"
onCancel={onClose}
onConfirm={onConfirmDelete}
alert={
(snippets[0]?.visibility as unknown as string) === 'project'
? {
title: 'This SQL snippet will be lost forever',
description:
'Deleting this query will remove it for all members of the project team.',
}
: undefined
}
>
<p className="text-sm">
This action cannot be undone.{' '}
{snippets.length === 1
? `Are you sure you want to delete '${snippets[0]?.name}'?`
: `Are you sure you want to delete the selected ${snippets.length} quer${snippets.length > 1 ? 'ies' : 'y'}?`}
</p>
</ConfirmationModal>
)
}

View File

@@ -1,4 +1,4 @@
import { Eye, EyeOffIcon, Heart, Unlock } from 'lucide-react'
import { Heart } from 'lucide-react'
import { useRouter } from 'next/router'
import { useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
@@ -14,8 +14,6 @@ import EditorMenuListSkeleton from 'components/layouts/TableEditorLayout/EditorM
import { useSqlEditorTabsCleanup } 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'
import { useContentUpsertMutation } from 'data/content/content-upsert-mutation'
import { useSQLSnippetFoldersDeleteMutation } from 'data/content/sql-folders-delete-mutation'
import { Snippet, SnippetFolder, useSQLSnippetFoldersQuery } from 'data/content/sql-folders-query'
import { useSqlSnippetsQuery } from 'data/content/sql-snippets-query'
@@ -23,13 +21,8 @@ import { useLocalStorage } from 'hooks/misc/useLocalStorage'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { useProfile } from 'lib/profile'
import uuidv4 from 'lib/uuid'
import {
SnippetWithContent,
useSnippetFolders,
useSqlEditorV2StateSnapshot,
} from 'state/sql-editor-v2'
import { useSnippetFolders, useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2'
import { createTabId, useTabsStateSnapshot } from 'state/tabs'
import { SqlSnippets } from 'types'
import { TreeView } from 'ui'
import {
InnerSideBarEmptyPanel,
@@ -40,10 +33,13 @@ import {
} from 'ui-patterns'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { CommunitySnippetsSection } from './CommunitySnippetsSection'
import { DeleteSnippetsModal } from './DeleteSnippetsModal'
import SQLEditorLoadingSnippets from './SQLEditorLoadingSnippets'
import { DEFAULT_SECTION_STATE, type SectionState } from './SQLEditorNav.constants'
import { formatFolderResponseForTreeView, getLastItemIds, ROOT_NODE } from './SQLEditorNav.utils'
import { SQLEditorTreeViewItem } from './SQLEditorTreeViewItem'
import { ShareSnippetModal } from './ShareSnippetModal'
import { UnshareSnippetModal } from './UnshareSnippetModal'
interface SQLEditorNavProps {
sort?: 'inserted_at' | 'name'
@@ -282,13 +278,7 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
// Snippet mutations from RQ
// ==========================
const { mutate: upsertContent, isLoading: isUpserting } = useContentUpsertMutation({
onError: (error) => {
toast.error(`Failed to update query: ${error.message}`)
},
})
const { mutate: deleteContent, isLoading: isDeleting } = useContentDeleteMutation({
const { mutate: deleteContent } = useContentDeleteMutation({
onSuccess: (data) => {
// 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
@@ -333,101 +323,6 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
if (ids.length > 0) ids.forEach((id) => snapV2.removeSnippet(id))
}
const onConfirmDelete = () => {
if (!projectRef) return console.error('Project ref is required')
deleteContent(
{ projectRef, ids: selectedSnippets.map((x) => x.id) },
{
onSuccess: (data) => {
toast.success(
`Successfully deleted ${selectedSnippets.length.toLocaleString()} quer${selectedSnippets.length > 1 ? 'ies' : 'y'}`
)
postDeleteCleanup(data)
},
}
)
}
const onUpdateVisibility = async (action: 'share' | 'unshare') => {
const snippet = action === 'share' ? selectedSnippetToShare : selectedSnippetToUnshare
if (!projectRef) return console.error('Project ref is required')
if (!snippet) return console.error('Snippet ID is required')
const storeSnippet = snapV2.snippets[snippet.id]
let snippetContent = storeSnippet?.snippet?.content
if (snippetContent === undefined) {
const { content } = await getContentById({ projectRef, id: snippet.id })
snippetContent = content as unknown as SqlSnippets.Content
}
// [Joshen] Just as a final check - to ensure that the content is minimally there (empty string is fine)
if (snippetContent === undefined) {
return toast.error('Unable to update snippet visibility: Content is missing')
}
const visibility = action === 'share' ? 'project' : 'user'
upsertContent(
{
projectRef,
payload: {
...snippet,
visibility,
folder_id: null,
content: snippetContent,
},
},
{
onSuccess: () => {
setSelectedSnippetToShare(undefined)
setSelectedSnippetToUnshare(undefined)
setSectionVisibility({ ...sectionVisibility, shared: true })
snapV2.updateSnippet({
id: snippet.id,
snippet: { visibility, folder_id: null },
skipSave: true,
})
toast.success(
action === 'share'
? 'Snippet is now shared to the project'
: 'Snippet is now unshared from the project'
)
},
}
)
}
const onSelectDuplicate = async (snippet: SnippetWithContent) => {
if (!profile) return console.error('Profile is required')
if (!project) return console.error('Project is required')
if (!projectRef) return console.error('Project ref is required')
if (!id) return console.error('Snippet ID is required')
let sql: string = ''
if (snippet.content && snippet.content.sql) {
sql = snippet.content.sql
} else {
// Fetch the content first
const { content } = await getContentById({ projectRef, id: snippet.id })
if ('sql' in content) {
sql = content.sql
}
}
const snippetCopy = createSqlSnippetSkeletonV2({
id: uuidv4(),
name: `${snippet.name} (Duplicate)`,
sql,
owner_id: profile?.id,
project_id: project?.id,
})
snapV2.addSnippet({ projectRef, snippet: snippetCopy })
snapV2.addNeedsSaving(snippetCopy.id!)
router.push(`/project/${projectRef}/sql/${snippetCopy.id}`)
}
const onConfirmDeleteFolder = async () => {
if (!projectRef) return console.error('Project ref is required')
if (selectedFolderToDelete === undefined) return console.error('No folder is selected')
@@ -601,9 +496,6 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
onSelectDownload={() => {
setSelectedSnippetToDownload(element.metadata as Snippet)
}}
onSelectDuplicate={() => {
onSelectDuplicate(element.metadata as Snippet)
}}
onSelectUnshare={() => {
setSelectedSnippetToUnshare(element.metadata as Snippet)
}}
@@ -683,9 +575,6 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
onSelectDownload={() => {
setSelectedSnippetToDownload(element.metadata as Snippet)
}}
onSelectDuplicate={() => {
onSelectDuplicate(element.metadata as Snippet)
}}
onSelectShare={() => setSelectedSnippetToShare(element.metadata as Snippet)}
onSelectUnshare={() => {
setSelectedSnippetToUnshare(element.metadata as Snippet)
@@ -801,7 +690,6 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
onSelectDownload={() =>
setSelectedSnippetToDownload(element.metadata as Snippet)
}
onSelectDuplicate={() => onSelectDuplicate(element.metadata as Snippet)}
onSelectShare={() => setSelectedSnippetToShare(element.metadata as Snippet)}
onEditSave={(name: string) => {
// [Joshen] Inline editing only for folders for now
@@ -861,83 +749,26 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
onCancel={() => setSelectedSnippetToDownload(undefined)}
/>
<ConfirmationModal
size="medium"
loading={isUpserting}
title={`Confirm to share query: ${selectedSnippetToShare?.name}`}
confirmLabel="Share query"
confirmLabelLoading="Sharing query"
visible={selectedSnippetToShare !== undefined}
onCancel={() => setSelectedSnippetToShare(undefined)}
onConfirm={() => onUpdateVisibility('share')}
alert={{
title: 'This SQL query will become public to all team members',
description: 'Anyone with access to the project can view it',
}}
>
<ul className="text-sm text-foreground-light space-y-5">
<li className="flex gap-3 items-center">
<Eye size={16} />
<span>Project members will have read-only access to this query.</span>
</li>
<li className="flex gap-3 items-center">
<Unlock size={16} />
<span>Anyone will be able to duplicate it to their personal snippets.</span>
</li>
</ul>
</ConfirmationModal>
<ShareSnippetModal
snippet={selectedSnippetToShare}
onClose={() => setSelectedSnippetToShare(undefined)}
onSuccess={() => setSectionVisibility({ ...sectionVisibility, shared: true })}
/>
<ConfirmationModal
size="medium"
title={`Confirm to unshare query: ${selectedSnippetToUnshare?.name}`}
confirmLabel="Unshare query"
confirmLabelLoading="Unsharing query"
visible={selectedSnippetToUnshare !== undefined}
onCancel={() => setSelectedSnippetToUnshare(undefined)}
onConfirm={() => onUpdateVisibility('unshare')}
alert={{
title: 'This SQL query will no longer be public to all team members',
description: 'Only you will have access to this query',
}}
>
<ul className="text-sm text-foreground-light space-y-5">
<li className="flex gap-3">
<EyeOffIcon />
<span>Project members will no longer be able to view this query.</span>
</li>
</ul>
</ConfirmationModal>
<UnshareSnippetModal
snippet={selectedSnippetToUnshare}
onClose={() => setSelectedSnippetToUnshare(undefined)}
onSuccess={() => setSectionVisibility({ ...sectionVisibility, private: true })}
/>
<ConfirmationModal
size="small"
title={`Confirm to delete ${selectedSnippets.length === 1 ? 'query' : `${selectedSnippets.length.toLocaleString()} quer${selectedSnippets.length > 1 ? 'ies' : 'y'}`}`}
confirmLabel={`Delete ${selectedSnippets.length.toLocaleString()} quer${selectedSnippets.length > 1 ? 'ies' : 'y'}`}
confirmLabelLoading="Deleting query"
loading={isDeleting}
<DeleteSnippetsModal
visible={showDeleteModal}
variant="destructive"
onCancel={() => {
snippets={selectedSnippets}
onClose={() => {
setShowDeleteModal(false)
setSelectedSnippets([])
}}
onConfirm={onConfirmDelete}
alert={
(selectedSnippets[0]?.visibility as unknown as string) === 'project'
? {
title: 'This SQL snippet will be lost forever',
description:
'Deleting this query will remove it for all members of the project team.',
}
: undefined
}
>
<p className="text-sm">
This action cannot be undone.{' '}
{selectedSnippets.length === 1
? `Are you sure you want to delete '${selectedSnippets[0]?.name}'?`
: `Are you sure you want to delete the selected ${selectedSnippets.length} quer${selectedSnippets.length > 1 ? 'ies' : 'y'}?`}
</p>
</ConfirmationModal>
/>
<ConfirmationModal
size="small"

View File

@@ -1,11 +1,4 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { IS_PLATFORM } from 'common'
import { useParams } from 'common/hooks/useParams'
import { useSQLSnippetFolderContentsQuery } from 'data/content/sql-folder-contents-query'
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,
@@ -21,6 +14,18 @@ import {
import Link from 'next/link'
import { useRouter } from 'next/router'
import { ComponentProps, useEffect } from 'react'
import { IS_PLATFORM } from 'common'
import { useParams } from 'common/hooks/useParams'
import { createSqlSnippetSkeletonV2 } from 'components/interfaces/SQLEditor/SQLEditor.utils'
import { getContentById } from 'data/content/content-id-query'
import { useSQLSnippetFolderContentsQuery } from 'data/content/sql-folder-contents-query'
import { Snippet } from 'data/content/sql-folders-query'
import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions'
import useLatest from 'hooks/misc/useLatest'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { useProfile } from 'lib/profile'
import uuidv4 from 'lib/uuid'
import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2'
import {
Button,
@@ -46,7 +51,6 @@ interface SQLEditorTreeViewItemProps
onSelectShare?: () => void
onSelectUnshare?: () => void
onSelectDownload?: () => void
onSelectDuplicate?: () => void
onSelectDeleteFolder?: () => void
onEditSave?: (name: string) => void
onMultiSelect?: (id: string) => void
@@ -77,7 +81,6 @@ export const SQLEditorTreeViewItem = ({
onSelectShare,
onSelectUnshare,
onSelectDownload,
onSelectDuplicate,
onEditSave,
onMultiSelect,
isLastItem,
@@ -92,6 +95,7 @@ export const SQLEditorTreeViewItem = ({
const router = useRouter()
const { id, ref: projectRef } = useParams()
const { profile } = useProfile()
const { data: project } = useSelectedProjectQuery()
const { className, onClick } = getNodeProps()
const snapV2 = useSqlEditorV2StateSnapshot()
@@ -102,10 +106,14 @@ export const SQLEditorTreeViewItem = ({
const isEditing = status === 'editing'
const isSaving = status === 'saving'
const canCreateSQLSnippet = useCheckPermissions(PermissionAction.CREATE, 'user_content', {
resource: { type: 'sql', owner_id: profile?.id },
subject: { id: profile?.id },
})
const { can: canCreateSQLSnippet } = useAsyncCheckProjectPermissions(
PermissionAction.CREATE,
'user_content',
{
resource: { type: 'sql', owner_id: profile?.id },
subject: { id: profile?.id },
}
)
const parentId = element.parent === 0 ? undefined : element.parent
@@ -175,6 +183,38 @@ export const SQLEditorTreeViewItem = ({
}
}
const onSelectDuplicate = async () => {
if (!profile) return console.error('Profile is required')
if (!project) return console.error('Project is required')
if (!projectRef) return console.error('Project ref is required')
if (!id) return console.error('Snippet ID is required')
const snippet = element.metadata
let sql: string = ''
if (snippet.content && snippet.content.sql) {
sql = snippet.content.sql
} else {
// Fetch the content first
const { content } = await getContentById({ projectRef, id: snippet.id })
if ('sql' in content) {
sql = content.sql
}
}
const snippetCopy = createSqlSnippetSkeletonV2({
id: uuidv4(),
name: `${snippet.name} (Duplicate)`,
sql,
owner_id: profile?.id,
project_id: project?.id,
})
snapV2.addSnippet({ projectRef, snippet: snippetCopy })
snapV2.addNeedsSaving(snippetCopy.id!)
router.push(`/project/${projectRef}/sql/${snippetCopy.id}`)
}
return (
<>
<ContextMenu_Shadcn_ modal={false}>

View File

@@ -1,23 +1,36 @@
import { Loader2 } from 'lucide-react'
import Link from 'next/link'
import { useMemo } from 'react'
import { useMemo, useState } from 'react'
import { useParams } from 'common'
import InfiniteList from 'components/ui/InfiniteList'
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 { Content } from 'data/content/content-query'
import { SNIPPET_PAGE_LIMIT } from 'data/content/sql-folders-query'
import { cn, SQL_ICON } from 'ui'
import { Snippet, SNIPPET_PAGE_LIMIT } from 'data/content/sql-folders-query'
import { createTabId, useTabsStateSnapshot } from 'state/tabs'
import { TreeView } from 'ui'
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
import { DeleteSnippetsModal } from './DeleteSnippetsModal'
import { formatFolderResponseForTreeView, getLastItemIds } from './SQLEditorNav.utils'
import { SQLEditorTreeViewItem } from './SQLEditorTreeViewItem'
import { ShareSnippetModal } from './ShareSnippetModal'
import { UnshareSnippetModal } from './UnshareSnippetModal'
interface SearchListProps {
search: string
}
export const SearchList = ({ search }: SearchListProps) => {
const { id } = useParams()
const tabs = useTabsStateSnapshot()
const { ref: projectRef } = useParams()
const [selectedSnippetToShare, setSelectedSnippetToShare] = useState<Snippet>()
const [selectedSnippetToUnshare, setSelectedSnippetToUnshare] = useState<Snippet>()
const [selectedSnippetToDownload, setSelectedSnippetToDownload] = useState<Snippet>()
const [selectedSnippetToRename, setSelectedSnippetToRename] = useState<Snippet>()
const [selectedSnippetToDelete, setSelectedSnippetToDelete] = useState<Snippet>()
const { data, isLoading, hasNextPage, fetchNextPage, isFetchingNextPage } =
useContentInfiniteQuery(
{
@@ -29,7 +42,7 @@ export const SearchList = ({ search }: SearchListProps) => {
{ keepPreviousData: true }
)
const { data: count } = useContentCountQuery(
const { data: count, isLoading: isLoadingCount } = useContentCountQuery(
{
projectRef,
cumulative: true,
@@ -41,56 +54,122 @@ export const SearchList = ({ search }: SearchListProps) => {
const totalNumber = (count as unknown as { count: number })?.count ?? 0
const snippets = useMemo(() => data?.pages.flatMap((page) => page.content), [data?.pages])
const treeState = formatFolderResponseForTreeView({ folders: [], contents: snippets as any })
const snippetsLastItemIds = useMemo(() => getLastItemIds(treeState), [treeState])
return (
<div className="flex flex-col flex-grow">
{isLoading ? (
<div className="px-4 py-1 pb-2.5">
<Loader2 className="animate-spin" size={14} />
</div>
) : !!count ? (
<p className="px-4 pb-2 text-sm text-foreground-lighter">
{totalNumber} result{totalNumber > 1 ? 's' : ''} found
</p>
) : null}
{isLoading ? (
<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>
) : (
<InfiniteList
items={snippets}
ItemComponent={(props) => <SearchListItem snippet={props.item} />}
itemProps={{}}
getItemSize={() => 28}
hasNextPage={hasNextPage}
isLoadingNextPage={isFetchingNextPage}
onLoadNextPage={() => fetchNextPage()}
/>
)}
</div>
)
}
<>
<div className="flex flex-col flex-grow">
{isLoadingCount ? (
<div className="px-4 py-1 pb-2.5">
<Loader2 className="animate-spin" size={14} />
</div>
) : !!count ? (
<p className="px-4 pb-2 text-sm text-foreground-lighter">
{totalNumber} result{totalNumber > 1 ? 's' : ''} found
</p>
) : null}
{isLoading ? (
<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
const SearchListItem = ({ snippet }: { snippet: Content }) => {
const { ref, id } = useParams()
const isSelected = snippet.id === id
return (
<Link
className={cn(
'h-full flex items-center gap-x-3 pl-4 hover:bg-control transition',
isSelected && '!bg-selection [&>svg]:fill-foreground [&>p]:text-foreground'
)}
href={`/project/${ref}/sql/${snippet.id}`}
>
<SQL_ICON
size={16}
strokeWidth={1.5}
className="w-5 h-5 -ml-0.5 transition-colors fill-foreground-muted group-aria-selected:fill-foreground"
return (
<SQLEditorTreeViewItem
{...props}
element={{
...element,
name: (
<span className="flex flex-col py-0.5">
<span title={element.name} className="truncate">
{element.name}
</span>
{!!visibility && (
<span title={visibility} className="text-foreground-lighter text-xs">
{visibility}
</span>
)}
</span>
),
}}
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)
}}
/>
)
}}
/>
)}
</div>
<ShareSnippetModal
snippet={selectedSnippetToShare}
onClose={() => setSelectedSnippetToShare(undefined)}
/>
<p className="transition text-sm text-foreground-light truncate">{snippet.name}</p>
</Link>
<UnshareSnippetModal
snippet={selectedSnippetToUnshare}
onClose={() => setSelectedSnippetToUnshare(undefined)}
/>
<DownloadSnippetModal
id={selectedSnippetToDownload?.id ?? ''}
visible={selectedSnippetToDownload !== undefined}
onCancel={() => setSelectedSnippetToDownload(undefined)}
/>
<RenameQueryModal
snippet={selectedSnippetToRename}
visible={!!selectedSnippetToRename}
onCancel={() => setSelectedSnippetToRename(undefined)}
onComplete={() => setSelectedSnippetToRename(undefined)}
/>
<DeleteSnippetsModal
visible={!!selectedSnippetToDelete}
snippets={!!selectedSnippetToDelete ? [selectedSnippetToDelete] : []}
onClose={() => setSelectedSnippetToDelete(undefined)}
/>
</>
)
}

View File

@@ -0,0 +1,99 @@
import { Eye, Unlock } from 'lucide-react'
import { toast } from 'sonner'
import { useParams } from 'common'
import { getContentById } from 'data/content/content-id-query'
import { useContentUpsertMutation } from 'data/content/content-upsert-mutation'
import { Snippet } from 'data/content/sql-folders-query'
import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2'
import { SqlSnippets } from 'types'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
export const ShareSnippetModal = ({
snippet,
onClose,
onSuccess,
}: {
snippet?: Snippet
onClose: () => void
onSuccess?: () => void
}) => {
const { ref: projectRef } = useParams()
const snapV2 = useSqlEditorV2StateSnapshot()
const { mutate: upsertContent, isLoading: isUpserting } = useContentUpsertMutation({
onError: (error) => {
toast.error(`Failed to update query: ${error.message}`)
},
})
const onShareSnippet = async () => {
if (!projectRef) return console.error('Project ref is required')
if (!snippet) return console.error('Snippet ID is required')
const storeSnippet = snapV2.snippets[snippet.id]
let snippetContent = storeSnippet?.snippet?.content
if (snippetContent === undefined) {
const { content } = await getContentById({ projectRef, id: snippet.id })
snippetContent = content as unknown as SqlSnippets.Content
}
// [Joshen] Just as a final check - to ensure that the content is minimally there (empty string is fine)
if (snippetContent === undefined) {
return toast.error('Unable to update snippet visibility: Content is missing')
}
upsertContent(
{
projectRef,
payload: {
...snippet,
visibility: 'project',
folder_id: null,
content: snippetContent,
},
},
{
onSuccess: () => {
snapV2.updateSnippet({
id: snippet.id,
snippet: { visibility: 'project', folder_id: null },
skipSave: true,
})
toast.success('Snippet is now shared to the project')
onSuccess?.()
onClose()
},
}
)
}
return (
<ConfirmationModal
size="medium"
loading={isUpserting}
title={`Confirm to share query: ${snippet?.name}`}
confirmLabel="Share query"
confirmLabelLoading="Sharing query"
visible={snippet !== undefined}
onCancel={() => onClose()}
onConfirm={() => onShareSnippet()}
alert={{
title: 'This SQL query will become public to all team members',
description: 'Anyone with access to the project can view it',
}}
>
<ul className="text-sm text-foreground-light space-y-5">
<li className="flex gap-3 items-center">
<Eye size={16} />
<span>Project members will have read-only access to this query.</span>
</li>
<li className="flex gap-3 items-center">
<Unlock size={16} />
<span>Anyone will be able to duplicate it to their personal snippets.</span>
</li>
</ul>
</ConfirmationModal>
)
}

View File

@@ -0,0 +1,93 @@
import { useParams } from 'common'
import { getContentById } from 'data/content/content-id-query'
import { useContentUpsertMutation } from 'data/content/content-upsert-mutation'
import { Snippet } from 'data/content/sql-folders-query'
import { EyeOffIcon } from 'lucide-react'
import { toast } from 'sonner'
import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2'
import { SqlSnippets } from 'types'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
export const UnshareSnippetModal = ({
snippet,
onClose,
onSuccess,
}: {
snippet?: Snippet
onClose: () => void
onSuccess?: () => void
}) => {
const { ref: projectRef } = useParams()
const snapV2 = useSqlEditorV2StateSnapshot()
const { mutate: upsertContent, isLoading: isUpserting } = useContentUpsertMutation({
onError: (error) => {
toast.error(`Failed to update query: ${error.message}`)
},
})
const onUnshareSnippet = async () => {
if (!projectRef) return console.error('Project ref is required')
if (!snippet) return console.error('Snippet ID is required')
const storeSnippet = snapV2.snippets[snippet.id]
let snippetContent = storeSnippet?.snippet?.content
if (snippetContent === undefined) {
const { content } = await getContentById({ projectRef, id: snippet.id })
snippetContent = content as unknown as SqlSnippets.Content
}
// [Joshen] Just as a final check - to ensure that the content is minimally there (empty string is fine)
if (snippetContent === undefined) {
return toast.error('Unable to update snippet visibility: Content is missing')
}
upsertContent(
{
projectRef,
payload: {
...snippet,
visibility: 'user',
folder_id: null,
content: snippetContent,
},
},
{
onSuccess: () => {
snapV2.updateSnippet({
id: snippet.id,
snippet: { visibility: 'user', folder_id: null },
skipSave: true,
})
toast.success('Snippet is now unshared from the project')
onSuccess?.()
onClose()
},
}
)
}
return (
<ConfirmationModal
size="medium"
title={`Confirm to unshare query: ${snippet?.name}`}
confirmLabel="Unshare query"
confirmLabelLoading="Unsharing query"
visible={snippet !== undefined}
onCancel={() => onClose()}
onConfirm={() => onUnshareSnippet()}
alert={{
title: 'This SQL query will no longer be public to all team members',
description: 'Only you will have access to this query',
}}
>
<ul className="text-sm text-foreground-light space-y-5">
<li className="flex gap-3">
<EyeOffIcon />
<span>Project members will no longer be able to view this query.</span>
</li>
</ul>
</ConfirmationModal>
)
}

View File

@@ -41,7 +41,10 @@ export const useContentDeleteMutation = ({
{
async onSuccess(data, variables, context) {
const { projectRef } = variables
await queryClient.invalidateQueries(contentKeys.allContentLists(projectRef))
await Promise.all([
queryClient.invalidateQueries(contentKeys.allContentLists(projectRef)),
queryClient.invalidateQueries(contentKeys.infiniteList(projectRef)),
])
await onSuccess?.(data, variables, context)
},

View File

@@ -52,7 +52,10 @@ export const useContentUpsertMutation = ({
async onSuccess(data, variables, context) {
const { projectRef } = variables
if (invalidateQueriesOnSuccess) {
await queryClient.invalidateQueries(contentKeys.allContentLists(projectRef))
await Promise.all([
queryClient.invalidateQueries(contentKeys.allContentLists(projectRef)),
queryClient.invalidateQueries(contentKeys.infiniteList(projectRef)),
])
}
await onSuccess?.(data, variables, context)
},

View File

@@ -5,13 +5,13 @@ export const contentKeys = {
allContentLists: (projectRef: string | undefined) => ['projects', projectRef, 'content'] as const,
infiniteList: (
projectRef: string | undefined,
options: {
options?: {
type: ContentType | undefined
name: string | undefined
limit?: number
sort?: string
}
) => ['projects', projectRef, 'content-infinite', options] as const,
) => ['projects', projectRef, 'content-infinite', options].filter(Boolean),
list: (
projectRef: string | undefined,
options: { type?: ContentType; name?: string; limit?: number }
@@ -35,7 +35,7 @@ export const contentKeys = {
options?: { sort?: 'inserted_at' | 'name'; name?: string }
) => ['projects', projectRef, 'content', 'folders', id, options].filter(Boolean),
resource: (projectRef: string | undefined, id?: string) =>
['projects', projectRef, 'content', id] as const,
['projects', projectRef, 'content-id', id] as const,
count: (
projectRef: string | undefined,
type?: string,