diff --git a/apps/studio/components/interfaces/Integrations/Vault/Secrets/__tests__/EditSecretModal.test.tsx b/apps/studio/components/interfaces/Integrations/Vault/Secrets/__tests__/EditSecretModal.test.tsx index 92fd59d67e162..b9423af032ae8 100644 --- a/apps/studio/components/interfaces/Integrations/Vault/Secrets/__tests__/EditSecretModal.test.tsx +++ b/apps/studio/components/interfaces/Integrations/Vault/Secrets/__tests__/EditSecretModal.test.tsx @@ -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', diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/DeleteSnippetsModal.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/DeleteSnippetsModal.tsx new file mode 100644 index 0000000000000..4e07cb6b0e53f --- /dev/null +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/DeleteSnippetsModal.tsx @@ -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 ( + 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 + } + > +

+ 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'}?`} +

+
+ ) +} diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx index 3a189539e115f..7cade33d6da85 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx @@ -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)} /> - 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', - }} - > - - + setSelectedSnippetToShare(undefined)} + onSuccess={() => setSectionVisibility({ ...sectionVisibility, shared: true })} + /> - 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', - }} - > - - + setSelectedSnippetToUnshare(undefined)} + onSuccess={() => setSectionVisibility({ ...sectionVisibility, private: true })} + /> - 1 ? 'ies' : 'y'}`}`} - confirmLabel={`Delete ${selectedSnippets.length.toLocaleString()} quer${selectedSnippets.length > 1 ? 'ies' : 'y'}`} - confirmLabelLoading="Deleting query" - loading={isDeleting} + { + 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 - } - > -

- 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'}?`} -

-
+ /> 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 ( <> diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SearchList.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SearchList.tsx index 9c74944dbc9d6..3faee12379609 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SearchList.tsx +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SearchList.tsx @@ -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() + const [selectedSnippetToUnshare, setSelectedSnippetToUnshare] = useState() + const [selectedSnippetToDownload, setSelectedSnippetToDownload] = useState() + const [selectedSnippetToRename, setSelectedSnippetToRename] = useState() + const [selectedSnippetToDelete, setSelectedSnippetToDelete] = useState() + 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 }) - return ( -
- {isLoading ? ( -
- -
- ) : !!count ? ( -

- {totalNumber} result{totalNumber > 1 ? 's' : ''} found -

- ) : null} - {isLoading ? ( -
- - - -
- ) : ( - } - itemProps={{}} - getItemSize={() => 28} - hasNextPage={hasNextPage} - isLoadingNextPage={isFetchingNextPage} - onLoadNextPage={() => fetchNextPage()} - /> - )} -
- ) -} + const snippetsLastItemIds = useMemo(() => getLastItemIds(treeState), [treeState]) -const SearchListItem = ({ snippet }: { snippet: Content }) => { - const { ref, id } = useParams() - const isSelected = snippet.id === id return ( - svg]:fill-foreground [&>p]:text-foreground' - )} - href={`/project/${ref}/sql/${snippet.id}`} - > - +
+ {isLoadingCount ? ( +
+ +
+ ) : !!count ? ( +

+ {totalNumber} result{totalNumber > 1 ? 's' : ''} found +

+ ) : null} + {isLoading ? ( +
+ + + +
+ ) : ( + { + 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 ( + + + {element.name} + + {!!visibility && ( + + {visibility} + + )} + + ), + }} + 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) + }} + /> + ) + }} + /> + )} +
+ + setSelectedSnippetToShare(undefined)} + /> + + setSelectedSnippetToUnshare(undefined)} + /> + + setSelectedSnippetToDownload(undefined)} + /> + + setSelectedSnippetToRename(undefined)} + onComplete={() => setSelectedSnippetToRename(undefined)} + /> + + setSelectedSnippetToDelete(undefined)} /> -

{snippet.name}

- + ) } diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/ShareSnippetModal.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/ShareSnippetModal.tsx new file mode 100644 index 0000000000000..c6e33f9922fa0 --- /dev/null +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/ShareSnippetModal.tsx @@ -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 ( + 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', + }} + > +
    +
  • + + Project members will have read-only access to this query. +
  • +
  • + + Anyone will be able to duplicate it to their personal snippets. +
  • +
+
+ ) +} diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/UnshareSnippetModal.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/UnshareSnippetModal.tsx new file mode 100644 index 0000000000000..3da90a17921d3 --- /dev/null +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/UnshareSnippetModal.tsx @@ -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 ( + 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', + }} + > +
    +
  • + + Project members will no longer be able to view this query. +
  • +
+
+ ) +} diff --git a/apps/studio/data/content/content-delete-mutation.ts b/apps/studio/data/content/content-delete-mutation.ts index e9b38b43beecc..d5fd129fa3f1b 100644 --- a/apps/studio/data/content/content-delete-mutation.ts +++ b/apps/studio/data/content/content-delete-mutation.ts @@ -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) }, diff --git a/apps/studio/data/content/content-upsert-mutation.ts b/apps/studio/data/content/content-upsert-mutation.ts index 2cbe9a173e4d5..e6dc7bc93e9e5 100644 --- a/apps/studio/data/content/content-upsert-mutation.ts +++ b/apps/studio/data/content/content-upsert-mutation.ts @@ -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) }, diff --git a/apps/studio/data/content/keys.ts b/apps/studio/data/content/keys.ts index bb13ee08472bb..2c803f01a3fa1 100644 --- a/apps/studio/data/content/keys.ts +++ b/apps/studio/data/content/keys.ts @@ -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,