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:
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user