diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobScheduleSection.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobScheduleSection.tsx index a5245ab1299..2a8ddeba408 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobScheduleSection.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobScheduleSection.tsx @@ -1,4 +1,3 @@ -import { toString as CronToString } from 'cronstrue' import { motion } from 'framer-motion' import { useEffect, useState } from 'react' import { UseFormReturn } from 'react-hook-form' @@ -26,7 +25,7 @@ import { } from 'ui' import { Input } from 'ui-patterns/DataInputs/Input' import { CreateCronJobForm } from './CreateCronJobSheet' -import { formatScheduleString, getScheduleMessage, secondsPattern } from './CronJobs.utils' +import { formatScheduleString, getScheduleMessage } from './CronJobs.utils' import CronSyntaxChart from './CronSyntaxChart' interface CronJobScheduleSectionProps { diff --git a/apps/studio/components/interfaces/Reports/Reports.CreateReportModal.tsx b/apps/studio/components/interfaces/Reports/Reports.CreateReportModal.tsx index 3ebd0cc902c..9a166080df5 100644 --- a/apps/studio/components/interfaces/Reports/Reports.CreateReportModal.tsx +++ b/apps/studio/components/interfaces/Reports/Reports.CreateReportModal.tsx @@ -21,7 +21,7 @@ export const CreateReportModal = ({ visible, onCancel, afterSubmit }: CreateRepo const { mutate: insertReport, isLoading: isCreating } = useContentInsertMutation({ onSuccess: (data) => { toast.success('Successfully created new report') - const newReportId = data[0].id + const newReportId = data.id router.push(`/project/${ref}/reports/${newReportId}`) afterSubmit() }, diff --git a/apps/studio/components/interfaces/Reports/Reports.UpdateModal.tsx b/apps/studio/components/interfaces/Reports/Reports.UpdateModal.tsx index 817fc988dcc..fca351e79b3 100644 --- a/apps/studio/components/interfaces/Reports/Reports.UpdateModal.tsx +++ b/apps/studio/components/interfaces/Reports/Reports.UpdateModal.tsx @@ -39,6 +39,7 @@ export const UpdateCustomReportModal = ({ projectRef: ref, payload: { ...selectedReport, + owner_id: selectedReport.owner_id!, project_id: selectedReport.project_id, id: selectedReport.id, name: newVals.name, diff --git a/apps/studio/components/interfaces/Reports/Reports.tsx b/apps/studio/components/interfaces/Reports/Reports.tsx index 5e68ba26e04..ead38cc7277 100644 --- a/apps/studio/components/interfaces/Reports/Reports.tsx +++ b/apps/studio/components/interfaces/Reports/Reports.tsx @@ -34,7 +34,10 @@ const Reports = () => { const [endDate, setEndDate] = useState(null) const [hasEdits, setHasEdits] = useState(false) - const { data: userContents, isLoading } = useContentQuery(ref) + const { data: userContents, isLoading } = useContentQuery({ + projectRef: ref, + type: 'report', + }) const { mutate: saveReport, isLoading: isSaving } = useContentUpdateMutation({ onSuccess: () => { setHasEdits(false) diff --git a/apps/studio/components/interfaces/SQLEditor/MonacoEditor.tsx b/apps/studio/components/interfaces/SQLEditor/MonacoEditor.tsx index 8a4900bf0b3..e58e89d8ec4 100644 --- a/apps/studio/components/interfaces/SQLEditor/MonacoEditor.tsx +++ b/apps/studio/components/interfaces/SQLEditor/MonacoEditor.tsx @@ -190,7 +190,7 @@ const MonacoEditor = ({ onMount={handleEditorOnMount} onChange={handleEditorChange} defaultLanguage="pgsql" - defaultValue={snippet?.snippet.content.sql} + defaultValue={snippet?.snippet.content?.sql} path={id} options={{ tabSize: 2, diff --git a/apps/studio/components/interfaces/SQLEditor/MoveQueryModal.tsx b/apps/studio/components/interfaces/SQLEditor/MoveQueryModal.tsx index 43942c99ea3..408ebce8798 100644 --- a/apps/studio/components/interfaces/SQLEditor/MoveQueryModal.tsx +++ b/apps/studio/components/interfaces/SQLEditor/MoveQueryModal.tsx @@ -9,8 +9,12 @@ import { useParams } from 'common' import { getContentById } from 'data/content/content-id-query' import { useContentUpsertV2Mutation } from 'data/content/content-upsert-v2-mutation' import { useSQLSnippetFolderCreateMutation } from 'data/content/sql-folder-create-mutation' -import { Snippet, SnippetDetail } from 'data/content/sql-folders-query' -import { useSnippetFolders, useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2' +import { Snippet } from 'data/content/sql-folders-query' +import { + SnippetWithContent, + useSnippetFolders, + useSqlEditorV2StateSnapshot, +} from 'state/sql-editor-v2' import { Button, CommandEmpty_Shadcn_, @@ -125,16 +129,18 @@ export const MoveQueryModal = ({ visible, snippets = [], onClose }: MoveQueryMod await Promise.all( snippets.map(async (snippet) => { - let snippetContent = (snippet as SnippetDetail)?.content + let snippetContent = (snippet as SnippetWithContent)?.content if (snippetContent === undefined) { const { content } = await getContentById({ projectRef: ref, id: snippet.id }) - snippetContent = content + if ('sql' in content) { + snippetContent = content + } } if (snippetContent === undefined) { return toast.error('Failed to save snippet: Unable to retrieve snippet contents') } else { - moveSnippetAsync({ + await moveSnippetAsync({ projectRef: ref, payload: { id: snippet.id, @@ -144,7 +150,7 @@ export const MoveQueryModal = ({ visible, snippets = [], onClose }: MoveQueryMod visibility: snippet.visibility, project_id: snippet.project_id, owner_id: snippet.owner_id, - folder_id: selectedId === 'root' ? (null as any) : folderId, + folder_id: selectedId === 'root' ? null : folderId, content: snippetContent as any, }, }) @@ -158,7 +164,7 @@ export const MoveQueryModal = ({ visible, snippets = [], onClose }: MoveQueryMod snippets.forEach((snippet) => { snapV2.updateSnippet({ id: snippet.id, - snippet: { ...snippet, folder_id: selectedId === 'root' ? (null as any) : selectedId }, + snippet: { ...snippet, folder_id: selectedId === 'root' ? null : selectedId }, skipSave: true, }) }) diff --git a/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx b/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx index a0e75bc8324..9684b7553ca 100644 --- a/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx +++ b/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx @@ -4,6 +4,7 @@ import { toast } from 'sonner' import { useParams } from 'common' import { useSqlTitleGenerateMutation } from 'data/ai/sql-title-mutation' import { getContentById } from 'data/content/content-id-query' +import { useContentUpdateMutation } from 'data/content/content-update-mutation' import { Snippet } from 'data/content/sql-folders-query' import type { SqlSnippet } from 'data/content/sql-snippets-query' import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' @@ -57,7 +58,9 @@ const RenameQueryModal = ({ } else { try { const { content } = await getContentById({ projectRef: ref, id: snippet.id }) - titleSql({ sql: content.sql }) + if ('sql' in content) { + titleSql({ sql: content.sql }) + } } catch (error) { toast.error('Unable to generate title based on query contents') } @@ -70,24 +73,44 @@ const RenameQueryModal = ({ return errors } + const { mutateAsync: updateContent } = useContentUpdateMutation() + const onSubmit = async (values: any, { setSubmitting }: any) => { if (!ref) return console.error('Project ref is required') if (!id) return console.error('Snippet ID is required') setSubmitting(true) try { - // [Joshen] For SQL V2 - content is loaded on demand so we need to fetch the data if its not already loaded in the valtio state - if (!('content' in snippet)) { - // [Joshen] I feel like there's definitely some optimization we can do here but will involve changes to API - const snippet = await getContentById({ projectRef: ref, id }) - snapV2.addSnippet({ projectRef: ref, snippet }) - } - snapV2.renameSnippet({ id, name: nameInput, description: descriptionInput }) + let localSnippet = snippet + // [Joshen] For SQL V2 - content is loaded on demand so we need to fetch the data if its not already loaded in the valtio state + if (!('content' in localSnippet)) { + localSnippet = await getContentById({ projectRef: ref, id }) + + snapV2.addSnippet({ projectRef: ref, snippet: localSnippet }) + } + + const updatedSnippet = await updateContent({ + projectRef: ref, + id, + type: localSnippet.type, + content: (localSnippet as any).content, + name: nameInput, + description: descriptionInput, + }) + + snapV2.renameSnippet({ + id, + name: updatedSnippet.name, + description: updatedSnippet.description, + }) + + toast.success('Successfully renamed snippet!') if (onComplete) onComplete() } catch (error: any) { + setSubmitting(false) // [Joshen] We probably need some rollback cause all the saving is async - toast.error(`Failed to rename query: ${error.message}`) + toast.error(`Failed to rename snippet: ${error.message}`) } } diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx b/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx index 05423c13ac0..fd4d7194cfb 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx @@ -227,7 +227,7 @@ export const SQLEditor = () => { const selection = editor.getSelection() const selectedValue = selection ? editor.getModel()?.getValueInRange(selection) : undefined const sql = snippet - ? (selectedValue || editorRef.current?.getValue()) ?? snippet.snippet.content.sql + ? (selectedValue || editorRef.current?.getValue()) ?? snippet.snippet.content?.sql : selectedValue || editorRef.current?.getValue() formatQuery( { @@ -267,7 +267,7 @@ export const SQLEditor = () => { const selectedValue = selection ? editor.getModel()?.getValueInRange(selection) : undefined const sql = snippet - ? (selectedValue || editorRef.current?.getValue()) ?? snippet.snippet.content.sql + ? (selectedValue || editorRef.current?.getValue()) ?? snippet.snippet.content?.sql : selectedValue || editorRef.current?.getValue() let queryHasIssues = false @@ -373,7 +373,9 @@ export const SQLEditor = () => { const result = snapV2.results[id]?.[0] appSnap.setAiAssistantPanel({ open: true, - sqlSnippets: [snippet.snippet.content.sql.replace(sqlAiDisclaimerComment, '').trim()], + sqlSnippets: [ + (snippet.snippet.content?.sql ?? '').replace(sqlAiDisclaimerComment, '').trim(), + ], initialInput: `Help me to debug the attached sql snippet which gives the following error: \n\n${result.error.message}`, }) } catch (error: unknown) { diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts b/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts index f3fbeac8b7a..01ebe12e902 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts @@ -1,5 +1,5 @@ -import { SnippetDetail } from 'data/content/sql-folders-query' import { removeCommentsFromSql } from 'lib/helpers' +import type { SnippetWithContent } from 'state/sql-editor-v2' import type { SqlSnippets, UserContent } from 'types' import { NEW_SQL_SNIPPET_SKELETON, @@ -52,7 +52,7 @@ export const createSqlSnippetSkeletonV2 = ({ owner_id: number project_id: number folder_id?: string -}): SnippetDetail => { +}): SnippetWithContent => { return { ...NEW_SQL_SNIPPET_SKELETON, id, diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx index 543d98d2af1..18f312ceabd 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx @@ -14,8 +14,6 @@ import { toast } from 'sonner' import { RoleImpersonationPopover } from 'components/interfaces/RoleImpersonationSelector' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import DatabaseSelector from 'components/ui/DatabaseSelector' -import { Content, ContentData } from 'data/content/content-query' -import { contentKeys } from 'data/content/keys' import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' import { IS_PLATFORM, LOCAL_STORAGE_KEYS } from 'lib/constants' import { detectOS } from 'lib/helpers' @@ -74,50 +72,10 @@ const UtilityActions = ({ const addFavorite = async () => { snapV2.addFavorite(id) - - client.setQueryData( - contentKeys.list(project?.ref), - (oldData: ContentData | undefined) => { - if (!oldData) return - - return { - ...oldData, - content: oldData.content.map((content: Content) => { - if (content.type === 'sql' && content.id === id) { - return { - ...content, - content: { ...content.content, favorite: true }, - } - } - return content - }), - } - } - ) } const removeFavorite = async () => { snapV2.removeFavorite(id) - - client.setQueryData( - contentKeys.list(project?.ref), - (oldData: ContentData | undefined) => { - if (!oldData) return - - return { - ...oldData, - content: oldData.content.map((content: Content) => { - if (content.type === 'sql' && content.id === id) { - return { - ...content, - content: { ...content.content, favorite: false }, - } - } - return content - }), - } - } - ) } return ( diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityPanel.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityPanel.tsx index b3c4c9901a6..07db5ddc1b7 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityPanel.tsx +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityPanel.tsx @@ -47,7 +47,6 @@ const UtilityPanel = ({ const snapV2 = useSqlEditorV2StateSnapshot() const snippet = snapV2.snippets[id]?.snippet - const queryKeys = contentKeys.list(ref) const result = snapV2.results[id]?.[0] const { mutate: upsertContent } = useContentUpsertMutation({ @@ -60,9 +59,6 @@ const UtilityPanel = ({ if (payload.type !== 'sql') return if (!('chart' in payload.content)) return - // Cancel any existing queries so that the new content is fetched - await queryClient.cancelQueries(queryKeys) - const newSnippet = { ...snippet, content: { @@ -83,7 +79,7 @@ const UtilityPanel = ({ return DEFAULT_CHART_CONFIG } - if (!snippet.content.chart) { + if (!snippet.content?.chart) { return DEFAULT_CHART_CONFIG } diff --git a/apps/studio/components/interfaces/Settings/Logs/Logs.SavedQueriesItem.tsx b/apps/studio/components/interfaces/Settings/Logs/Logs.SavedQueriesItem.tsx index 9bf3aa1a65a..6f4f2d8d23b 100644 --- a/apps/studio/components/interfaces/Settings/Logs/Logs.SavedQueriesItem.tsx +++ b/apps/studio/components/interfaces/Settings/Logs/Logs.SavedQueriesItem.tsx @@ -17,6 +17,7 @@ interface SavedQueriesItemProps { id: string name: string description?: string + owner_id: number content: { sql: string } diff --git a/apps/studio/components/layouts/LogsLayout/LogsSidebarMenuV2.tsx b/apps/studio/components/layouts/LogsLayout/LogsSidebarMenuV2.tsx index bad2bbd1f44..b689362727c 100644 --- a/apps/studio/components/layouts/LogsLayout/LogsSidebarMenuV2.tsx +++ b/apps/studio/components/layouts/LogsLayout/LogsSidebarMenuV2.tsx @@ -96,7 +96,10 @@ export function LogsSidebarMenuV2() { { enabled: IS_PLATFORM && warehouseEnabled && !!tenantData } ) - const { data: savedQueriesRes, isLoading: savedQueriesLoading } = useContentQuery(ref) + const { data: savedQueriesRes, isLoading: savedQueriesLoading } = useContentQuery({ + projectRef: ref, + type: 'log_sql', + }) const savedQueries = [...(savedQueriesRes?.content ?? [])] .filter((c) => c.type === 'log_sql') diff --git a/apps/studio/components/layouts/ReportsLayout/ReportsMenu.tsx b/apps/studio/components/layouts/ReportsLayout/ReportsMenu.tsx index dbb19165499..684eefb283e 100644 --- a/apps/studio/components/layouts/ReportsLayout/ReportsMenu.tsx +++ b/apps/studio/components/layouts/ReportsLayout/ReportsMenu.tsx @@ -31,7 +31,10 @@ const ReportsMenu = () => { subject: { id: profile?.id }, }) - const { data: content, isLoading } = useContentQuery(ref) + const { data: content, isLoading } = useContentQuery({ + projectRef: ref, + type: 'report', + }) const { mutate: deleteReport, isLoading: isDeleting } = useContentDeleteMutation({ onSuccess: () => { setDeleteModalOpen(false) @@ -154,7 +157,7 @@ const ReportsMenu = () => { {reportMenuItems.map((item) => ( { setSelectedReportToUpdate(item.report) diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx index dce32b418ac..a78a22c387f 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx @@ -27,6 +27,7 @@ import { InnerSideMenuItem, } from 'ui-patterns/InnerSideMenu' import { SQLEditorNav as SQLEditorNavV2 } from './SQLEditorNavV2/SQLEditorNav' +import { useLocalStorage } from 'hooks/misc/useLocalStorage' interface SQLEditorMenuProps { onViewOngoingQueries: () => void @@ -40,6 +41,8 @@ export const SQLEditorMenu = ({ onViewOngoingQueries }: SQLEditorMenuProps) => { const snapV2 = useSqlEditorV2StateSnapshot() const [searchText, setSearchText] = useState('') + const [isSearching, setIsSearching] = useState(false) + const [sort, setSort] = useLocalStorage<'name' | 'inserted_at'>('sql-editor-sort', 'inserted_at') const canCreateSQLSnippet = useCheckPermissions(PermissionAction.CREATE, 'user_content', { resource: { type: 'sql', owner_id: profile?.id }, @@ -47,11 +50,8 @@ export const SQLEditorMenu = ({ onViewOngoingQueries }: SQLEditorMenuProps) => { }) const createNewFolder = () => { - // [Joshen] LEFT OFF: We need to figure out a good UX for creating folders - // - Modal? Directly chuck into the tree view like storage explorer? if (!ref) return console.error('Project ref is required') snapV2.addNewFolder({ projectRef: ref }) - // createFolder({ projectRef: ref, name: 'test' }) } const handleNewQuery = async () => { @@ -61,17 +61,8 @@ export const SQLEditorMenu = ({ onViewOngoingQueries }: SQLEditorMenuProps) => { if (!canCreateSQLSnippet) { return toast('Your queries will not be saved as you do not have sufficient permissions') } - try { - const snippet = createSqlSnippetSkeletonV2({ - id: uuidv4(), - name: untitledSnippetTitle, - owner_id: profile.id, - project_id: project.id, - sql: '', - }) - snapV2.addSnippet({ projectRef: ref, snippet }) - router.push(`/project/${ref}/sql/${snippet.id}`) + router.push(`/project/${ref}/sql/new?skip=true`) setSearchText('') } catch (error: any) { toast.error(`Failed to create new query: ${error.message}`) @@ -89,10 +80,11 @@ export const SQLEditorMenu = ({ onViewOngoingQueries }: SQLEditorMenuProps) => { aria-labelledby="Search queries" value={searchText} onChange={(e) => setSearchText(e.target.value)} + isLoading={isSearching} > snapV2.setOrder(value)} + value={sort} + onValueChange={(value: any) => setSort(value)} > Alphabetical @@ -141,7 +133,7 @@ export const SQLEditorMenu = ({ onViewOngoingQueries }: SQLEditorMenuProps) => { - +
diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorLoadingSnippets.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorLoadingSnippets.tsx new file mode 100644 index 00000000000..e2ca70874fc --- /dev/null +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorLoadingSnippets.tsx @@ -0,0 +1,30 @@ +import { Skeleton } from 'ui' + +const SQLEditorLoadingSnippets = () => { + return ( + <> +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + ) +} + +export default SQLEditorLoadingSnippets diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx index 9562476804d..35d57d567a1 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx @@ -1,6 +1,7 @@ +import { useDebounce } from '@uidotdev/usehooks' import { Eye, EyeOffIcon, Heart, Unlock } from 'lucide-react' import { useRouter } from 'next/router' -import { useEffect, useState } from 'react' +import { Dispatch, SetStateAction, useEffect, useMemo, useState } from 'react' import { toast } from 'sonner' import { useParams } from 'common' @@ -13,25 +14,17 @@ 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 { useSQLSnippetFoldersDeleteMutation } from 'data/content/sql-folders-delete-mutation' -import { - Snippet, - SnippetDetail, - SnippetFolder, - getSQLSnippetFolders, - useSQLSnippetFoldersQuery, -} from 'data/content/sql-folders-query' +import { Snippet, SnippetFolder, useSQLSnippetFoldersQuery } from 'data/content/sql-folders-query' import { useSqlSnippetsQuery } from 'data/content/sql-snippets-query' -import { useLocalStorage } from 'hooks/misc/useLocalStorage' import { useSelectedProject } from 'hooks/misc/useSelectedProject' import { useProfile } from 'lib/profile' import uuidv4 from 'lib/uuid' import { - useFavoriteSnippets, + SnippetWithContent, useSnippetFolders, - useSnippets, useSqlEditorV2StateSnapshot, } from 'state/sql-editor-v2' -import { Separator, Skeleton, TreeView } from 'ui' +import { cn, Separator, TreeView } from 'ui' import { InnerSideBarEmptyPanel, InnerSideMenuCollapsible, @@ -40,27 +33,38 @@ import { InnerSideMenuSeparator, } from 'ui-patterns' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' -import { ROOT_NODE, formatFolderResponseForTreeView } from './SQLEditorNav.utils' +import SQLEditorLoadingSnippets from './SQLEditorLoadingSnippets' +import { ROOT_NODE, formatFolderResponseForTreeView, getLastItemIds } from './SQLEditorNav.utils' import { SQLEditorTreeViewItem } from './SQLEditorTreeViewItem' +import { useContentUpsertV2Mutation } from 'data/content/content-upsert-v2-mutation' +import { SqlSnippets } from 'types' interface SQLEditorNavProps { searchText: string + sort?: 'inserted_at' | 'name' + setIsSearching?: Dispatch> } -export const SQLEditorNav = ({ searchText: _searchText }: SQLEditorNavProps) => { +export const SQLEditorNav = ({ + searchText: _searchText, + sort = 'inserted_at', + setIsSearching, +}: SQLEditorNavProps) => { const searchText = _searchText.trim() + const debouncedSearchText = useDebounce(searchText, 250) + const isSearching = searchText.length > 0 + const router = useRouter() const { profile } = useProfile() const project = useSelectedProject() const { ref: projectRef, id } = useParams() const snapV2 = useSqlEditorV2StateSnapshot() - const [sort] = useLocalStorage<'name' | 'inserted_at'>('sql-editor-sort', 'inserted_at') const [mountedId, setMountedId] = useState(false) const [showMoveModal, setShowMoveModal] = useState(false) const [showDeleteModal, setShowDeleteModal] = useState(false) const [showRenameModal, setShowRenameModal] = useState(false) - const [showFavouriteSnippets, setShowFavouriteSnippets] = useState(false) + const [showFavoriteSnippets, setShowFavoriteSnippets] = useState(false) const [showSharedSnippets, setShowSharedSnippets] = useState(false) const [showPrivateSnippets, setShowPrivateSnippets] = useState(true) @@ -72,76 +76,267 @@ export const SQLEditorNav = ({ searchText: _searchText }: SQLEditorNavProps) => const [selectedSnippetToDownload, setSelectedSnippetToDownload] = useState() const [selectedFolderToDelete, setSelectedFolderToDelete] = useState() - // ======================================================= - // [Joshen] Set up favorites, shared, and private snippets - // ======================================================= - const snippets = useSnippets(projectRef as string) - const folders = useSnippetFolders(projectRef as string) - const contents = snippets.filter((x) => - searchText.length > 0 ? x.name.toLowerCase().includes(searchText.toLowerCase()) : true - ) const snippet = snapV2.snippets[id as string]?.snippet - const privateSnippets = contents.filter((snippet) => snippet.visibility === 'user') - const numPrivateSnippets = snapV2.privateSnippetCount[projectRef as string] - const privateSnippetsTreeState = - folders.length === 0 && snippets.length === 0 - ? [ROOT_NODE] - : formatFolderResponseForTreeView({ folders, contents: privateSnippets }) - - const favoriteSnippets = useFavoriteSnippets(projectRef as string).filter((x) => - searchText.length > 0 ? x.name.toLowerCase().includes(searchText.toLowerCase()) : true + // ========================== + // Private snippets & folders + // ========================== + const { + data: privateSnippetsPages, + isLoading, + isPreviousData, + isFetching, + hasNextPage, + fetchNextPage, + isFetchingNextPage, + } = useSQLSnippetFoldersQuery( + { projectRef, name: debouncedSearchText, sort }, + { keepPreviousData: true } ) - const numFavoriteSnippets = favoriteSnippets.length - const favoritesTreeState = - numFavoriteSnippets === 0 - ? [ROOT_NODE] - : formatFolderResponseForTreeView({ contents: favoriteSnippets as any }) - const projectSnippets = contents.filter((snippet) => snippet.visibility === 'project') - const numProjectSnippets = projectSnippets.length - const projectSnippetsTreeState = - numProjectSnippets === 0 - ? [ROOT_NODE] - : formatFolderResponseForTreeView({ contents: projectSnippets }) + useEffect(() => { + if (projectRef && privateSnippetsPages) { + privateSnippetsPages.pages.forEach((page) => { + page.contents?.forEach((snippet) => { + snapV2.addSnippet({ + projectRef, + snippet, + }) + }) - // ================================= - // [Joshen] React Queries - // ================================= + page.folders?.forEach((folder) => { + snapV2.addFolder({ projectRef, folder }) + }) + }) + } + }, [projectRef, privateSnippetsPages?.pages]) - useSQLSnippetFoldersQuery( - { projectRef }, - { - refetchOnWindowFocus: false, - staleTime: 5 * 60 * 1000, // 5 minutes - onSuccess: (data) => { - if (projectRef !== undefined) { - snapV2.initializeRemoteSnippets({ projectRef, data, sort }) + const [subResults, setSubResults] = useState<{ + [id: string]: { snippets?: Snippet[]; isLoading: boolean } + }>({}) + + const filteredSnippets = useMemo(() => { + const rootSnippets = privateSnippetsPages?.pages.flatMap((page) => page.contents ?? []) ?? [] + + let snippetInfo = Object.values(subResults).reduce( + ( + acc: { + snippets: Snippet[] + isLoading: boolean + snippetIds: Set + }, + curr + ) => { + // filter out snippets that already exist + const newSnippets = (curr.snippets ?? []).filter( + (snippet) => !acc.snippetIds.has(snippet.id) + ) + const newSnippetIds = new Set(newSnippets.map((snippet) => snippet.id)) + + return { + snippets: [...acc.snippets, ...newSnippets], + isLoading: acc.isLoading || curr.isLoading, + snippetIds: new Set([...acc.snippetIds, ...newSnippetIds]), } }, + { + snippets: rootSnippets, + isLoading: isLoading || (isPreviousData && isFetching), + snippetIds: new Set(rootSnippets.map((snippet) => snippet.id)), + } + ) + + if (snippet && snippet.visibility === 'user' && !snippetInfo.snippetIds.has(snippet.id)) { + snippetInfo.snippetIds.add(snippet.id) + snippetInfo.snippets = [...snippetInfo.snippets, snippet] } + + return snippetInfo + }, [privateSnippetsPages?.pages, subResults, isLoading, isPreviousData, isFetching, snippet]) + + const privateSnippets = useMemo( + () => + filteredSnippets.snippets + ?.filter((snippet) => snippet.visibility === 'user') + .sort((a, b) => { + if (sort === 'name') return a.name.localeCompare(b.name) + else return new Date(b.inserted_at).valueOf() - new Date(a.inserted_at).valueOf() + }) ?? [], + [filteredSnippets.snippets, sort] + ) + const folders = useSnippetFolders(projectRef as string) + + const { data: privateSnippetCountData } = useContentCountQuery({ + projectRef, + type: 'sql', + visibility: 'user', + name: debouncedSearchText, + }) + const numPrivateSnippets = privateSnippetCountData?.count ?? 0 + + const privateSnippetsTreeState = useMemo( + () => + folders.length === 0 && privateSnippets.length === 0 + ? [ROOT_NODE] + : formatFolderResponseForTreeView({ folders, contents: privateSnippets }), + [folders, privateSnippets] ) - useSqlSnippetsQuery(projectRef, { - onSuccess(data) { - if (projectRef !== undefined) { - const favoriteSnippets = data.snippets.filter((snippet) => snippet.content.favorite) - snapV2.initializeFavoriteSnippets({ projectRef, snippets: favoriteSnippets }) - } + const privateSnippetsLastItemIds = useMemo( + () => getLastItemIds(privateSnippetsTreeState), + [privateSnippetsTreeState] + ) + + // ================= + // Favorite snippets + // ================= + const { + data: favoriteSqlSnippetsData, + isLoading: isLoadingFavoriteSqlSnippets, + hasNextPage: hasMoreFavoriteSqlSnippets, + fetchNextPage: fetchNextFavoriteSqlSnippets, + isFetchingNextPage: isFetchingMoreFavoriteSqlSnippets, + isSuccess: isFavoriteSnippetsSuccess, + } = useSqlSnippetsQuery( + { + projectRef, + favorite: true, + name: debouncedSearchText, + sort, + }, + { enabled: showFavoriteSnippets, keepPreviousData: true } + ) + + useEffect(() => { + if (projectRef === undefined || !isFavoriteSnippetsSuccess) return + + favoriteSqlSnippetsData.pages.forEach((page) => { + page.contents?.forEach((snippet) => { + snapV2.addSnippet({ + projectRef, + snippet, + }) + }) + }) + }, [projectRef, privateSnippetsPages?.pages]) + + const favoriteSnippets = useMemo(() => { + let snippets = favoriteSqlSnippetsData?.pages.flatMap((page) => page.contents ?? []) ?? [] + + if (snippet && snippet.favorite && !snippets.find((x) => x.id === snippet.id)) { + snippets.push(snippet as any) + } + + return ( + snippets + .map((snippet) => ({ ...snippet, folder_id: undefined })) + .sort((a, b) => { + if (sort === 'name') return a.name.localeCompare(b.name) + else return new Date(b.inserted_at).valueOf() - new Date(a.inserted_at).valueOf() + }) ?? [] + ) + }, [favoriteSqlSnippetsData?.pages, snippet, sort]) + + const { data: favoritedSnippetCountData } = useContentCountQuery({ + projectRef, + type: 'sql', + favorite: true, + name: debouncedSearchText, + }) + const numFavoriteSnippets = favoritedSnippetCountData?.count ?? 0 + + const favoritesTreeState = useMemo( + () => + numFavoriteSnippets === 0 + ? [ROOT_NODE] + : formatFolderResponseForTreeView({ contents: favoriteSnippets }), + [favoriteSnippets, numFavoriteSnippets] + ) + + const favoriteSnippetsLastItemIds = useMemo( + () => getLastItemIds(favoritesTreeState), + [favoritesTreeState] + ) + + // ================= + // Shared snippets + // ================= + const { + data: sharedSqlSnippetsData, + isLoading: isLoadingSharedSqlSnippets, + hasNextPage: hasMoreSharedSqlSnippets, + fetchNextPage: fetchNextSharedSqlSnippets, + isFetchingNextPage: isFetchingMoreSharedSqlSnippets, + isSuccess: isSharedSqlSnippetsSuccess, + } = useSqlSnippetsQuery( + { + projectRef, + visibility: 'project', + name: debouncedSearchText, + sort, + }, + { enabled: showSharedSnippets, keepPreviousData: true } + ) + + useEffect(() => { + if (projectRef === undefined || !isSharedSqlSnippetsSuccess) return + + sharedSqlSnippetsData.pages.forEach((page) => { + page.contents?.forEach((snippet) => { + snapV2.addSnippet({ + projectRef, + snippet, + }) + }) + }) + }, [projectRef, privateSnippetsPages?.pages]) + + const sharedSnippets = useMemo(() => { + let snippets = sharedSqlSnippetsData?.pages.flatMap((page) => page.contents ?? []) ?? [] + + if (snippet && snippet.visibility === 'project' && !snippets.find((x) => x.id === snippet.id)) { + snippets.push(snippet as any) + } + + return ( + snippets.sort((a, b) => { + if (sort === 'name') return a.name.localeCompare(b.name) + else return new Date(b.inserted_at).valueOf() - new Date(a.inserted_at).valueOf() + }) ?? [] + ) + }, [sharedSqlSnippetsData?.pages, snippet, sort]) + + const { data: sharedSnippetCountData } = useContentCountQuery({ + projectRef, + type: 'sql', + visibility: 'project', + name: debouncedSearchText, + }) + const numProjectSnippets = sharedSnippetCountData?.count ?? 0 + + const projectSnippetsTreeState = useMemo( + () => + numProjectSnippets === 0 + ? [ROOT_NODE] + : formatFolderResponseForTreeView({ contents: sharedSnippets }), + [sharedSnippets, numProjectSnippets] + ) + + const projectSnippetsLastItemIds = useMemo( + () => getLastItemIds(projectSnippetsTreeState), + [projectSnippetsTreeState] + ) + + // ========================== + // Snippet mutations from RQ + // ========================== + + const { mutate: upsertContent, isLoading: isUpserting } = useContentUpsertV2Mutation({ + onError: (error) => { + toast.error(`Failed to update query: ${error.message}`) }, }) - useContentCountQuery( - { projectRef, type: 'sql' }, - { - onSuccess(data) { - if (projectRef !== undefined) { - snapV2.setPrivateSnippetCount({ projectRef, value: data.count }) - } - }, - } - ) - const { mutate: deleteContent, isLoading: isDeleting } = useContentDeleteMutation({ onError: (error, data) => { if (error.message.includes('Contents not found')) { @@ -161,11 +356,13 @@ export const SQLEditorNav = ({ searchText: _searchText }: SQLEditorNavProps) => }, }) - // ================================= - // [Joshen] UI functions - // ================================= + // =============== + // UI functions + // =============== const postDeleteCleanup = (ids: string[]) => { + // [Refactor] To investigate - deleting a snippet while it's open, will have it in the side nav + // for a bit, before it gets removed (assumingly invalidated) setShowDeleteModal(false) setSelectedSnippets([]) const existingSnippetIds = Object.keys(snapV2.snippets).filter((x) => !ids.includes(x)) @@ -194,47 +391,71 @@ export const SQLEditorNav = ({ searchText: _searchText }: SQLEditorNavProps) => ) } - const onConfirmShare = () => { - if (!selectedSnippetToShare) return console.error('Snippet ID is required') - snapV2.shareSnippet(selectedSnippetToShare.id, 'project') - setSelectedSnippetToShare(undefined) - setShowSharedSnippets(true) + 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') - if (projectRef !== undefined) { - snapV2.setPrivateSnippetCount({ - projectRef, - value: snapV2.privateSnippetCount[projectRef] - 1, - }) + 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) + setShowSharedSnippets(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 onConfirmUnshare = () => { - if (!selectedSnippetToUnshare) return console.error('Snippet ID is required') - snapV2.shareSnippet(selectedSnippetToUnshare.id, 'user') - setSelectedSnippetToUnshare(undefined) - setShowPrivateSnippets(true) - - if (projectRef !== undefined) { - snapV2.setPrivateSnippetCount({ - projectRef, - value: snapV2.privateSnippetCount[projectRef] + 1, - }) - } - } - - const onSelectCopyPersonal = async (snippet: Snippet) => { + const onSelectCopyPersonal = 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 (!('content' in snippet)) { + if (snippet.content && snippet.content.sql) { + sql = snippet.content.sql + } else { // Fetch the content first const { content } = await getContentById({ projectRef, id: snippet.id }) - sql = content.sql - } else { - sql = (snippet as SnippetDetail).content.sql + if ('sql' in content) { + sql = content.sql + } } const snippetCopy = createSqlSnippetSkeletonV2({ @@ -254,7 +475,7 @@ export const SQLEditorNav = ({ searchText: _searchText }: SQLEditorNavProps) => if (!projectRef) return console.error('Project ref is required') if (selectedFolderToDelete === undefined) return console.error('No folder is selected') - const folderSnippets = contents.filter( + const folderSnippets = privateSnippets.filter( (content) => content.folder_id === selectedFolderToDelete.id ) if (folderSnippets.length > 0) { @@ -280,7 +501,7 @@ export const SQLEditorNav = ({ searchText: _searchText }: SQLEditorNavProps) => // We're only supporting shift clicks (not cmd/control click) - this is even with the react tree view component itself const onMultiSelect = (selectedId: string) => { // The base is always the current query thats selected - const contentIds = contents.map((x) => x.id) + const contentIds = privateSnippets.map((x) => x.id) const baseIndex = contentIds.indexOf(id as string) const targetIndex = contentIds.indexOf(selectedId) @@ -288,11 +509,12 @@ export const SQLEditorNav = ({ searchText: _searchText }: SQLEditorNavProps) => const ceiling = Math.max(baseIndex, targetIndex) const _selectedSnippets = [] - const sameFolder = contents[floor].folder_id === contents[ceiling].folder_id + const sameFolder = privateSnippets[floor].folder_id === privateSnippets[ceiling].folder_id for (let i = floor; i <= ceiling; i++) { if (sameFolder) { - if (contents[i].folder_id === contents[floor].folder_id) _selectedSnippets.push(contents[i]) + if (privateSnippets[i].folder_id === privateSnippets[floor].folder_id) + _selectedSnippets.push(privateSnippets[i]) } else { // [Joshen] Temp don't allow selecting across folders for now // _selectedSnippets.push(contents[i]) @@ -302,29 +524,25 @@ export const SQLEditorNav = ({ searchText: _searchText }: SQLEditorNavProps) => setSelectedSnippets(_selectedSnippets) } - // ====================================== - // [Joshen] useEffects kept at the bottom - // ====================================== + // =============== + // useEffects + // =============== useEffect(() => { - const loadFolderContents = async (folderId: string) => { - const { contents } = await getSQLSnippetFolders({ projectRef, folderId }) - if (projectRef) { - contents?.forEach((snippet) => snapV2.addSnippet({ projectRef, snippet })) - } - } + setIsSearching?.(filteredSnippets.isLoading) + }, [filteredSnippets.isLoading, setIsSearching]) + useEffect(() => { if (snippet !== undefined && !mountedId) { if (snippet.visibility === 'project') setShowSharedSnippets(true) if (snippet.folder_id) { setDefaultExpandedFolderIds([snippet.folder_id]) - loadFolderContents(snippet.folder_id) } // Only want to run this once when loading sql/[id] route setMountedId(true) } - }, [snippet, mountedId]) + }, [snippet, mountedId, sort, debouncedSearchText]) useEffect(() => { // Unselect all snippets whenever opening another snippet @@ -334,116 +552,126 @@ export const SQLEditorNav = ({ searchText: _searchText }: SQLEditorNavProps) => return ( <> - {((numProjectSnippets === 0 && searchText.length === 0) || numProjectSnippets > 0) && ( - <> - - 0 ? ` (${numProjectSnippets})` : ''}`} + + 0 ? ` (${numProjectSnippets})` : ''}`} + /> + + {isLoadingSharedSqlSnippets ? ( + + ) : numProjectSnippets === 0 ? ( + div>p]:text-foreground-lighter' : '')} + title={isSearching ? 'No results found based on your search' : 'No shared queries'} + description={ + isSearching + ? undefined + : 'Share queries with your team by right-clicking on the query' + } /> - - {numProjectSnippets === 0 ? ( - - ) : ( - ( - { - setShowDeleteModal(true) - setSelectedSnippets([element.metadata as unknown as Snippet]) - }} - onSelectRename={() => { - setShowRenameModal(true) - setSelectedSnippetToRename(element.metadata as Snippet) - }} - onSelectDownload={() => { - setSelectedSnippetToDownload(element.metadata as Snippet) - }} - onSelectCopyPersonal={() => { - onSelectCopyPersonal(element.metadata as Snippet) - }} - onSelectUnshare={() => { - setSelectedSnippetToUnshare(element.metadata as Snippet) - }} - /> - )} + ) : ( + ( + { + setShowDeleteModal(true) + setSelectedSnippets([element.metadata as unknown as Snippet]) + }} + onSelectRename={() => { + setShowRenameModal(true) + setSelectedSnippetToRename(element.metadata as Snippet) + }} + onSelectDownload={() => { + setSelectedSnippetToDownload(element.metadata as Snippet) + }} + onSelectCopyPersonal={() => { + onSelectCopyPersonal(element.metadata as Snippet) + }} + onSelectUnshare={() => { + setSelectedSnippetToUnshare(element.metadata as Snippet) + }} + isLastItem={projectSnippetsLastItemIds.has(element.id as string)} + hasNextPage={hasMoreSharedSqlSnippets} + fetchNextPage={fetchNextSharedSqlSnippets} + isFetchingNextPage={isFetchingMoreSharedSqlSnippets} /> )} - - - - - )} + /> + )} + + + - {((numFavoriteSnippets === 0 && searchText.length === 0) || numFavoriteSnippets > 0) && ( - <> - - 0 ? ` (${numFavoriteSnippets})` : ''}`} + + 0 ? ` (${numFavoriteSnippets})` : ''}`} + /> + + {isLoadingFavoriteSqlSnippets ? ( + + ) : numFavoriteSnippets === 0 ? ( + div>p]:text-foreground-lighter' : '')} + description={ + isSearching ? null : ( + <> + Save a query to favorites for easy accessibility by clicking the{' '} + {' '} + icon. + + ) + } /> - - {numFavoriteSnippets === 0 ? ( - - Save a query to favorites for easy accessbility by clicking the{' '} - {' '} - icon. - - } - /> - ) : ( - ( - { - setShowDeleteModal(true) - setSelectedSnippets([element.metadata as unknown as Snippet]) - }} - onSelectRename={() => { - setShowRenameModal(true) - setSelectedSnippetToRename(element.metadata as Snippet) - }} - onSelectDownload={() => { - setSelectedSnippetToDownload(element.metadata as Snippet) - }} - onSelectCopyPersonal={() => { - onSelectCopyPersonal(element.metadata as Snippet) - }} - onSelectShare={() => setSelectedSnippetToShare(element.metadata as Snippet)} - onSelectUnshare={() => { - setSelectedSnippetToUnshare(element.metadata as Snippet) - }} - /> - )} + ) : ( + ( + { + setShowDeleteModal(true) + setSelectedSnippets([element.metadata as unknown as Snippet]) + }} + onSelectRename={() => { + setShowRenameModal(true) + setSelectedSnippetToRename(element.metadata as Snippet) + }} + onSelectDownload={() => { + setSelectedSnippetToDownload(element.metadata as Snippet) + }} + onSelectCopyPersonal={() => { + onSelectCopyPersonal(element.metadata as Snippet) + }} + onSelectShare={() => setSelectedSnippetToShare(element.metadata as Snippet)} + onSelectUnshare={() => { + setSelectedSnippetToUnshare(element.metadata as Snippet) + }} + isLastItem={favoriteSnippetsLastItemIds.has(element.id as string)} + hasNextPage={hasMoreFavoriteSqlSnippets} + fetchNextPage={fetchNextFavoriteSqlSnippets} + isFetchingNextPage={isFetchingMoreFavoriteSqlSnippets} /> )} - - - - - )} + /> + )} + + + ${numPrivateSnippets > 0 ? ` (${numPrivateSnippets})` : ''}`} /> - {!snapV2.loaded[projectRef as string] ? ( - <> -
- - -
-
- - -
-
- - -
-
- - -
-
- - -
- + {isLoading ? ( + ) : folders.length === 0 && numPrivateSnippets === 0 ? ( {...props} element={element} isMultiSelected={selectedSnippets.length > 1} + isLastItem={privateSnippetsLastItemIds.has(element.id as string)} status={props.isBranch ? snapV2.folders[element.id].status : 'idle'} onMultiSelect={onMultiSelect} onSelectCreate={() => { @@ -548,6 +756,17 @@ export const SQLEditorNav = ({ searchText: _searchText }: SQLEditorNavProps) => snapV2.saveFolder({ id: element.id as string, name }) } }} + hasNextPage={hasNextPage} + fetchNextPage={fetchNextPage} + isFetchingNextPage={isFetchingNextPage} + sort={sort} + name={debouncedSearchText} + onFolderContentsChange={({ isLoading, snippets }) => { + setSubResults((prev) => ({ + ...prev, + [element.id as string]: { snippets, isLoading }, + })) + }} /> )} /> @@ -581,12 +800,13 @@ export const SQLEditorNav = ({ searchText: _searchText }: SQLEditorNavProps) => setSelectedSnippetToShare(undefined)} - onConfirm={onConfirmShare} + 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', @@ -611,7 +831,7 @@ export const SQLEditorNav = ({ searchText: _searchText }: SQLEditorNavProps) => confirmLabelLoading="Unsharing query" visible={selectedSnippetToUnshare !== undefined} onCancel={() => setSelectedSnippetToUnshare(undefined)} - onConfirm={onConfirmUnshare} + 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', diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.utils.ts b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.utils.ts index 0a412f3c82e..ee581b3958d 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.utils.ts +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.utils.ts @@ -8,7 +8,7 @@ export interface TreeViewItemProps { metadata?: any } -export const ROOT_NODE = { id: 0, name: '', parent: null, children: [] } +export const ROOT_NODE: TreeViewItemProps = { id: 0, name: '', parent: null, children: [] } // [Joshen] At the moment this is only tuned for single level folders // Will need to relook at this for multi level folders, @@ -52,3 +52,28 @@ export const formatFolderResponseForTreeView = ( return [root, ...formattedFolders, ...formattedContents] } + +export function getLastItemIds(items: TreeViewItemProps[]) { + let lastItemIds = new Set() + + const topLevelItems = items.filter((item) => item.parent === 0) + + if (topLevelItems.length > 0) { + const lastItem = topLevelItems[topLevelItems.length - 1] + if (typeof lastItem.id === 'string') { + lastItemIds.add(lastItem.id) + } + + topLevelItems.forEach((item) => { + if (item.children.length > 0) { + const childrenLastItem = item.children[item.children.length - 1] + + if (typeof childrenLastItem === 'string') { + lastItemIds.add(childrenLastItem) + } + } + }) + } + + return lastItemIds +} diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorTreeViewItem.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorTreeViewItem.tsx index d8b728746e2..b48f097dea3 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorTreeViewItem.tsx +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorTreeViewItem.tsx @@ -2,16 +2,18 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { Copy, Download, Edit, ExternalLink, Lock, Move, Plus, Share, Trash } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/router' -import { useState } from 'react' -import { toast } from 'sonner' +import { useEffect } from 'react' import { IS_PLATFORM } from 'common' import { useParams } from 'common/hooks/useParams' -import { getSQLSnippetFolders } from 'data/content/sql-folders-query' +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 { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2' import { + Button, ContextMenuContent_Shadcn_, ContextMenuItem_Shadcn_, ContextMenuSeparator_Shadcn_, @@ -40,6 +42,15 @@ interface SQLEditorTreeViewItemProps { onSelectDeleteFolder?: () => void onEditSave?: (name: string) => void onMultiSelect?: (id: string) => void + + // Pagination/filtering options + isLastItem: boolean + hasNextPage?: boolean + fetchNextPage?: () => void + isFetchingNextPage?: boolean + sort?: 'inserted_at' | 'name' + name?: string + onFolderContentsChange?: (info: { isLoading: boolean; snippets?: Snippet[] }) => void } export const SQLEditorTreeViewItem = ({ @@ -61,15 +72,20 @@ export const SQLEditorTreeViewItem = ({ onSelectCopyPersonal, onEditSave, onMultiSelect, + isLastItem, + hasNextPage: _hasNextPage, + fetchNextPage: _fetchNextPage, + isFetchingNextPage: _isFetchingNextPage, + sort, + name, + onFolderContentsChange, }: SQLEditorTreeViewItemProps) => { const router = useRouter() - const { id, ref } = useParams() + const { id, ref: projectRef } = useParams() const { profile } = useProfile() const { className, onClick } = getNodeProps() const snapV2 = useSqlEditorV2StateSnapshot() - const [isFetching, setIsFetching] = useState(false) - const isOwner = profile?.id === element?.metadata.owner_id const isSharedSnippet = element.metadata.visibility === 'project' @@ -81,20 +97,63 @@ export const SQLEditorTreeViewItem = ({ subject: { id: profile?.id }, }) - // [Joshen] Folder contents are loaded on demand too - const onOpenFolder = async (id: string) => { - if (!ref) return console.error('Project ref is required') + const parentId = element.parent === 0 ? undefined : element.parent - try { - setIsFetching(true) - const { contents } = await getSQLSnippetFolders({ projectRef: ref, folderId: id }) - contents?.forEach((snippet) => { - snapV2.addSnippet({ projectRef: ref, snippet }) + const isEnabled = isBranch && isExpanded + + const { + data, + isSuccess, + isLoading, + isFetchingNextPage: isFetchingNextPageInFolder, + hasNextPage: hasNextPageInFolder, + fetchNextPage: fetchNestPageInFolder, + isPreviousData, + isFetching, + } = useSQLSnippetFolderContentsQuery( + { + projectRef, + folderId: parentId ?? element.id, + name, + sort, + }, + { + enabled: isEnabled, + keepPreviousData: true, + } + ) + useEffect(() => { + if (projectRef && isSuccess) { + data.pages.forEach((page) => { + page.contents?.forEach((snippet) => { + snapV2.addSnippet({ + projectRef, + snippet, + }) + }) }) - } catch (error: any) { - toast.error(`Failed to retrieve folder contents: ${error.message}`) - } finally { - setIsFetching(false) + } + }, [projectRef, data?.pages]) + + const onFolderContentsChangeRef = useLatest(onFolderContentsChange) + useEffect(() => { + if (isEnabled) { + onFolderContentsChangeRef.current?.({ + isLoading: isLoading || (isPreviousData && isFetching), + snippets: data?.pages.flatMap((page) => page.contents ?? []), + }) + } + }, [data?.pages, isFetching, isLoading, isPreviousData, isEnabled]) + + const isInFolder = parentId !== undefined + + const hasNextPage = isInFolder ? hasNextPageInFolder : _hasNextPage + + function fetchNextPage() { + if (isInFolder) { + fetchNestPageInFolder() + } else if (typeof _fetchNextPage === 'function') { + _fetchNextPage() } } @@ -111,18 +170,18 @@ export const SQLEditorTreeViewItem = ({ isBranch={isBranch} isSelected={isSelected || id === element.id} isEditing={isEditing} - isLoading={isFetching || isSaving} + isLoading={(isEnabled && isLoading) || isSaving} onEditSubmit={(value) => { if (onEditSave !== undefined) onEditSave(value) }} onClick={(e) => { if (!isBranch) { if (!e.shiftKey) { - router.push(`/project/${ref}/sql/${element.id}`) + router.push(`/project/${projectRef}/sql/${element.id}`) } else if (id !== 'new') { onMultiSelect?.(element.id) } else { - router.push(`/project/${ref}/sql/${element.id}`) + router.push(`/project/${projectRef}/sql/${element.id}`) } } else { // Prevent expanding folder while editing text @@ -132,7 +191,6 @@ export const SQLEditorTreeViewItem = ({ } onClick(e) - if (!isExpanded) onOpenFolder(element.id) } }} /> @@ -206,7 +264,11 @@ export const SQLEditorTreeViewItem = ({ onSelect={() => {}} onFocusCapture={(e) => e.stopPropagation()} > - + Open in new tab @@ -288,6 +350,27 @@ export const SQLEditorTreeViewItem = ({ )} + + {hasNextPage && typeof element.id === 'string' && isLastItem && ( +
1 ? 48 * (element.level - 1) : undefined, + }} + > + +
+ )} ) } diff --git a/apps/studio/components/layouts/SQLEditorLayout/SqlEditor.Commands.tsx b/apps/studio/components/layouts/SQLEditorLayout/SqlEditor.Commands.tsx index 7e2a8253966..6268fb21e55 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SqlEditor.Commands.tsx +++ b/apps/studio/components/layouts/SQLEditorLayout/SqlEditor.Commands.tsx @@ -91,7 +91,16 @@ export function useSnippetCommands() { function RunSnippetPage() { const { ref } = useParams() - const { data: snippets, isLoading, isError, isSuccess } = useSqlSnippetsQuery(ref) + const { + data: snippetPages, + isLoading, + isError, + isSuccess, + } = useSqlSnippetsQuery({ + projectRef: ref, + }) + + const snippets = snippetPages?.pages.flatMap((page) => page.contents) const { profile } = useProfile() const canCreateSQLSnippet = useCheckPermissions(PermissionAction.CREATE, 'user_content', { @@ -109,15 +118,11 @@ function RunSnippetPage() { {isLoading && } {isError && } - {isSuccess && (!snippets || snippets.snippets.length === 0) && ( + {isSuccess && (!snippets || snippets.length === 0) && ( )} - {isSuccess && !!snippets && snippets.snippets.length > 0 && ( - + {isSuccess && !!snippets && snippets.length > 0 && ( + )} ) @@ -239,7 +244,7 @@ function SnippetSelector({ function snippetValue(snippet: SqlSnippet) { if (snippet.type !== 'sql') return '' return escapeAttributeSelector( - `${snippet.id}-${snippet.name}-${snippet.content.sql.slice(0, 30)}` + `${snippet.id}-${snippet.name}-${snippet?.content?.sql.slice(0, 30)}` ).toLowerCase() } diff --git a/apps/studio/data/content/content-count-query.ts b/apps/studio/data/content/content-count-query.ts index 2a751b19d0e..71f17fd2471 100644 --- a/apps/studio/data/content/content-count-query.ts +++ b/apps/studio/data/content/content-count-query.ts @@ -3,20 +3,32 @@ import { useQuery, UseQueryOptions } from '@tanstack/react-query' import { get, handleError } from 'data/fetchers' import { ResponseError } from 'types' import { contentKeys } from './keys' +import { SqlSnippet } from './sql-snippets-query' -interface getContentCountVariables { - projectRef?: string +type GetContentFilters = { type: 'sql' | 'report' | 'log_sql' + visibility?: SqlSnippet['visibility'] + favorite?: boolean + name?: string +} + +interface getContentCountVariables extends GetContentFilters { + projectRef?: string } export async function getContentCount( - { projectRef, type }: getContentCountVariables, + { projectRef, type, visibility, favorite, name }: getContentCountVariables, signal?: AbortSignal ) { if (typeof projectRef === 'undefined') throw new Error('projectRef is required') + const query: GetContentFilters = { type } + if (visibility) query.visibility = visibility + if (favorite) query.favorite = favorite + if (name) query.name = name + const { data, error } = await get('/platform/projects/{ref}/content/count', { - params: { path: { ref: projectRef }, query: { type } }, + params: { path: { ref: projectRef }, query: { type, visibility, favorite, name } }, signal, }) @@ -28,12 +40,16 @@ export type ContentIdData = Awaited> export type ContentIdError = ResponseError export const useContentCountQuery = ( - { projectRef, type }: getContentCountVariables, + { projectRef, type, visibility, favorite, name }: getContentCountVariables, { enabled = true, ...options }: UseQueryOptions = {} ) => useQuery( - contentKeys.count(projectRef, type), - ({ signal }) => getContentCount({ projectRef, type }, signal), + contentKeys.count(projectRef, type, { + visibility, + favorite, + name, + }), + ({ signal }) => getContentCount({ projectRef, type, visibility, favorite, name }, signal), { enabled: enabled && typeof projectRef !== 'undefined', ...options, diff --git a/apps/studio/data/content/content-delete-mutation.ts b/apps/studio/data/content/content-delete-mutation.ts index b427770dc04..bb4a2d9df54 100644 --- a/apps/studio/data/content/content-delete-mutation.ts +++ b/apps/studio/data/content/content-delete-mutation.ts @@ -41,7 +41,7 @@ export const useContentDeleteMutation = ({ { async onSuccess(data, variables, context) { const { projectRef } = variables - await queryClient.invalidateQueries(contentKeys.list(projectRef)) + await queryClient.invalidateQueries(contentKeys.allContentLists(projectRef)) await onSuccess?.(data, variables, context) }, async onError(data, variables, context) { diff --git a/apps/studio/data/content/content-id-query.ts b/apps/studio/data/content/content-id-query.ts index 24332a90a79..d2db7a33799 100644 --- a/apps/studio/data/content/content-id-query.ts +++ b/apps/studio/data/content/content-id-query.ts @@ -1,9 +1,18 @@ import { useQuery, UseQueryOptions } from '@tanstack/react-query' +import { components } from 'api-types' import { get, handleError } from 'data/fetchers' -import { ResponseError } from 'types' +import type { ResponseError } from 'types' +import type { Content } from './content-query' import { contentKeys } from './keys' +export type GetUserContentByIdResponse = Omit< + components['schemas']['GetUserContentByIdResponse'], + 'content' +> & { + content: Content['content'] +} + export async function getContentById( { projectRef, id }: { projectRef?: string; id?: string }, signal?: AbortSignal @@ -17,7 +26,8 @@ export async function getContentById( }) if (error) throw handleError(error) - return data + // override content type + return data as unknown as GetUserContentByIdResponse } export type ContentIdData = Awaited> diff --git a/apps/studio/data/content/content-insert-mutation.ts b/apps/studio/data/content/content-insert-mutation.ts index af51d57ce1a..a2c621d7deb 100644 --- a/apps/studio/data/content/content-insert-mutation.ts +++ b/apps/studio/data/content/content-insert-mutation.ts @@ -7,7 +7,10 @@ import type { ResponseError } from 'types' import type { Content } from './content-query' import { contentKeys } from './keys' -export type InsertContentPayload = Omit & { +export type InsertContentPayload = Omit< + components['schemas']['CreateContentBodyDto'], + 'content' +> & { content: Content['content'] } @@ -32,13 +35,12 @@ export async function insertContent( type: payload.type, visibility: payload.visibility, content: payload.content as any, + folder_id: payload.folder_id, }, signal, }) if (error) handleError(error) - - // [Joshen] There's an issue with the API codegen due to content endpoint having 2 versions - return data as unknown as InsertContentResponse[] + return data } export type InsertContentData = Awaited> @@ -58,7 +60,7 @@ export const useContentInsertMutation = ({ { async onSuccess(data, variables, context) { const { projectRef } = variables - await queryClient.invalidateQueries(contentKeys.list(projectRef)) + await queryClient.invalidateQueries(contentKeys.allContentLists(projectRef)) await onSuccess?.(data, variables, context) }, async onError(data, variables, context) { diff --git a/apps/studio/data/content/content-query.ts b/apps/studio/data/content/content-query.ts index 18d650ee14b..efcda7d5d66 100644 --- a/apps/studio/data/content/content-query.ts +++ b/apps/studio/data/content/content-query.ts @@ -1,25 +1,13 @@ import { useQuery, UseQueryOptions } from '@tanstack/react-query' -import { get } from 'lib/common/fetch' -import { API_URL } from 'lib/constants' -import type { Dashboards, LogSqlSnippets, Owner, SqlSnippets } from 'types' +import { components } from 'api-types' +import { get } from 'data/fetchers' +import type { Dashboards, LogSqlSnippets, SqlSnippets } from 'types' import { contentKeys } from './keys' -export type ContentBase = { - id: string - name: string - description?: string - visibility: 'user' | 'project' | 'org' | 'public' - owner_id?: number // user id - last_updated_by?: number // user id - inserted_at?: string // '2021-08-26T08:24:52.040695+00:00' - owner?: Owner - project_id?: number - updated_at?: string // '2021-08-26T08:24:52.040695+00:00' - updated_by?: Owner -} +export type ContentBase = components['schemas']['GetUserContentObject'] -export type Content = ContentBase & +export type Content = Omit & ( | { type: 'sql' @@ -35,30 +23,30 @@ export type Content = ContentBase & } ) -export type ContentType = 'sql' | 'report' | 'log_sql' +export type ContentType = Content['type'] -export async function getContent( - projectRef: string | undefined, - signal?: AbortSignal -): Promise<{ - content: Content[] -}> { +interface GetContentVariables { + projectRef?: string + type: ContentType +} + +export async function getContent({ projectRef, type }: GetContentVariables, signal?: AbortSignal) { if (typeof projectRef === 'undefined') { throw new Error('projectRef is required for getContent') } - let response = await get(`${API_URL}/projects/${projectRef}/content`, { signal }) + const { data, error } = await get('/platform/projects/{ref}/content', { + params: { path: { ref: projectRef }, query: { type } }, + signal, + }) - if (response.error) { - throw response.error - } - - if (!response) { - throw new Error('Content not found') + if (error) { + throw error } return { - content: response.data, + cursor: data.cursor, + content: data.data as unknown as Content[], } } @@ -66,11 +54,11 @@ export type ContentData = Awaited> export type ContentError = unknown export const useContentQuery = ( - projectRef: string | undefined, + { projectRef, type }: GetContentVariables, { enabled = true, ...options }: UseQueryOptions = {} ) => useQuery( - contentKeys.list(projectRef), - ({ signal }) => getContent(projectRef, signal), + contentKeys.list(projectRef, type), + ({ signal }) => getContent({ projectRef, type }, signal), { enabled: enabled && typeof projectRef !== 'undefined', ...options } ) diff --git a/apps/studio/data/content/content-update-mutation.ts b/apps/studio/data/content/content-update-mutation.ts index 5301e16a931..e6b871acb3e 100644 --- a/apps/studio/data/content/content-update-mutation.ts +++ b/apps/studio/data/content/content-update-mutation.ts @@ -6,24 +6,30 @@ import type { ResponseError } from 'types' import { contentKeys } from './keys' import { ContentType } from './content-query' -type UpdateContentVariables = { projectRef: string; id: string; type: ContentType; content: any } +type UpdateContentVariables = { + projectRef: string + id: string + type: ContentType + content: any + name?: string + description?: string +} export async function updateContent( - { projectRef, id, type, content }: UpdateContentVariables, + { projectRef, id, type, content, name, description }: UpdateContentVariables, signal?: AbortSignal ) { const { data, error } = await patch('/platform/projects/{ref}/content', { params: { - // @ts-ignore API codegen issue path: { ref: projectRef }, query: { id }, }, - body: { id, type, content }, + body: { id, type, content, name, description }, signal, }) if (error) handleError(error) - return data + return data[0] } type UpdateContentData = Awaited> @@ -43,7 +49,7 @@ export const useContentUpdateMutation = ({ { async onSuccess(data, variables, context) { const { projectRef } = variables - await queryClient.invalidateQueries(contentKeys.list(projectRef)) + await queryClient.invalidateQueries(contentKeys.allContentLists(projectRef)) await onSuccess?.(data, variables, context) }, async onError(data, variables, context) { diff --git a/apps/studio/data/content/content-upsert-mutation.ts b/apps/studio/data/content/content-upsert-mutation.ts index a9362ee38c9..f0a2655afc0 100644 --- a/apps/studio/data/content/content-upsert-mutation.ts +++ b/apps/studio/data/content/content-upsert-mutation.ts @@ -7,7 +7,10 @@ import type { ResponseError } from 'types' import type { Content } from './content-query' import { contentKeys } from './keys' -export type UpsertContentPayload = Omit & { +export type UpsertContentPayload = Omit< + components['schemas']['UpsertContentBodyDto'], + 'content' +> & { content: Partial } @@ -31,6 +34,7 @@ export async function upsertContent( type: payload.type, visibility: payload.visibility, content: payload.content as any, + folder_id: payload.folder_id, }, signal, }) @@ -60,7 +64,7 @@ export const useContentUpsertMutation = ({ async onSuccess(data, variables, context) { const { projectRef } = variables if (invalidateQueriesOnSuccess) { - await queryClient.invalidateQueries(contentKeys.list(projectRef)) + await queryClient.invalidateQueries(contentKeys.allContentLists(projectRef)) } await onSuccess?.(data, variables, context) }, diff --git a/apps/studio/data/content/content-upsert-v2-mutation.ts b/apps/studio/data/content/content-upsert-v2-mutation.ts index ed824792038..e456c1a7fce 100644 --- a/apps/studio/data/content/content-upsert-v2-mutation.ts +++ b/apps/studio/data/content/content-upsert-v2-mutation.ts @@ -8,7 +8,7 @@ import type { Content } from './content-query' import { contentKeys } from './keys' export type UpsertContentPayloadV2 = Omit< - components['schemas']['UpsertContentBodyV2'], + components['schemas']['UpsertContentBodyDto'], 'content' > & { content: Content['content'] @@ -24,7 +24,6 @@ export async function upsertContent( signal?: AbortSignal ) { const { data, error } = await put('/platform/projects/{ref}/content', { - // @ts-ignore API codegen is wrong, any is also cause of API codegen being unable to handle versioning params: { path: { ref: projectRef } }, body: payload as any, headers: { Version: '2' }, @@ -56,13 +55,13 @@ export const useContentUpsertV2Mutation = ({ async onSuccess(data, variables, context) { const { projectRef } = variables if (invalidateQueriesOnSuccess) { - await queryClient.invalidateQueries(contentKeys.list(projectRef)) + await queryClient.invalidateQueries(contentKeys.allContentLists(projectRef)) } await onSuccess?.(data, variables, context) }, async onError(data, variables, context) { if (onError === undefined) { - toast.error(`Failed to insert content: ${data.message}`) + toast.error(`Failed to upsert content: ${data.message}`) } else { onError(data, variables, context) } diff --git a/apps/studio/data/content/keys.ts b/apps/studio/data/content/keys.ts index 5daf8d84b20..3e9317d4356 100644 --- a/apps/studio/data/content/keys.ts +++ b/apps/studio/data/content/keys.ts @@ -1,9 +1,33 @@ +import type { ContentType } from './content-query' +import type { SqlSnippet } from './sql-snippets-query' + export const contentKeys = { - list: (projectRef: string | undefined) => ['projects', projectRef, 'content'] as const, - folders: (projectRef: string | undefined, id?: string) => - ['projects', projectRef, 'content', 'folders', id].filter(Boolean), + allContentLists: (projectRef: string | undefined) => ['projects', projectRef, 'content'] as const, + list: (projectRef: string | undefined, type: ContentType | undefined) => + ['projects', projectRef, 'content', type] as const, + sqlSnippets: ( + projectRef: string | undefined, + options?: { + sort?: 'inserted_at' | 'name' + name?: string + visibility?: SqlSnippet['visibility'] + favorite?: boolean + } + ) => ['projects', projectRef, 'content', 'sql', options].filter(Boolean), + folders: ( + projectRef: string | undefined, + options?: { sort?: 'inserted_at' | 'name'; name?: string } + ) => ['projects', projectRef, 'content', 'folders', options].filter(Boolean), + folderContents: ( + projectRef: string | undefined, + id?: string, + 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, - count: (projectRef: string | undefined, type?: string) => - ['projects', projectRef, 'content', 'count', type].filter(Boolean), + count: ( + projectRef: string | undefined, + type?: string, + options?: { visibility?: SqlSnippet['visibility']; favorite?: boolean; name?: string } + ) => ['projects', projectRef, 'content', 'count', type, options].filter(Boolean), } diff --git a/apps/studio/data/content/sql-folder-contents-query.ts b/apps/studio/data/content/sql-folder-contents-query.ts new file mode 100644 index 00000000000..f83b394140b --- /dev/null +++ b/apps/studio/data/content/sql-folder-contents-query.ts @@ -0,0 +1,64 @@ +import { useInfiniteQuery, UseInfiniteQueryOptions } from '@tanstack/react-query' + +import { get, handleError } from 'data/fetchers' +import { ResponseError } from 'types' +import { contentKeys } from './keys' + +export type SQLSnippetFolderContentsVariables = { + projectRef?: string + folderId?: string + cursor?: string + name?: string + sort?: 'name' | 'inserted_at' +} + +export async function getSQLSnippetFolderContents( + { projectRef, folderId, cursor, sort, name }: SQLSnippetFolderContentsVariables, + signal?: AbortSignal +) { + if (typeof projectRef === 'undefined') throw new Error('projectRef is required') + if (typeof folderId === 'undefined') throw new Error('folderId is required') + + const sortOrder = sort === 'name' ? 'asc' : 'desc' + + const { data, error } = await get('/platform/projects/{ref}/content/folders/{id}', { + params: { + path: { ref: projectRef, id: folderId }, + query: { cursor, limit: '3', sort_by: sort, sort_order: sortOrder, name }, + }, + signal, + }) + + if (error) throw handleError(error) + return { + ...data.data, + cursor: data.cursor, + } +} + +export type SQLSnippetFolderContentsData = Awaited> +export type SQLSnippetFolderContentsError = ResponseError + +export const useSQLSnippetFolderContentsQuery = ( + { projectRef, folderId, name, sort }: Omit, + { + enabled = true, + ...options + }: UseInfiniteQueryOptions< + SQLSnippetFolderContentsData, + SQLSnippetFolderContentsError, + TData + > = {} +) => + useInfiniteQuery( + contentKeys.folderContents(projectRef, folderId, { name, sort }), + ({ signal, pageParam }) => + getSQLSnippetFolderContents({ projectRef, folderId, cursor: pageParam, name, sort }, signal), + { + enabled: enabled && typeof projectRef !== 'undefined' && typeof folderId !== 'undefined', + getNextPageParam(lastPage) { + return lastPage.cursor + }, + ...options, + } + ) diff --git a/apps/studio/data/content/sql-folders-query.ts b/apps/studio/data/content/sql-folders-query.ts index 099dab2d6fd..4da5cf1cf89 100644 --- a/apps/studio/data/content/sql-folders-query.ts +++ b/apps/studio/data/content/sql-folders-query.ts @@ -1,37 +1,52 @@ -import { useQuery, UseQueryOptions } from '@tanstack/react-query' +import { useInfiniteQuery, UseInfiniteQueryOptions } from '@tanstack/react-query' +import { components } from 'api-types' import { get, handleError } from 'data/fetchers' import { ResponseError } from 'types' import { contentKeys } from './keys' -import { components } from 'api-types' export type SnippetFolderResponse = components['schemas']['GetUserContentFolderResponse']['data'] export type SnippetFolder = components['schemas']['UserContentFolder'] export type Snippet = components['schemas']['UserContentObjectMeta'] -export type SnippetDetail = components['schemas']['UserContentObjectV2'] + +export type SQLSnippetFolderVariables = { + projectRef?: string + cursor?: string + name?: string + sort?: 'name' | 'inserted_at' +} + +export const SNIPPET_PAGE_LIMIT = 100 export async function getSQLSnippetFolders( - { projectRef, folderId }: { projectRef?: string; folderId?: string }, + { projectRef, cursor, sort, name }: SQLSnippetFolderVariables, signal?: AbortSignal ) { if (typeof projectRef === 'undefined') throw new Error('projectRef is required') - if (folderId) { - const { data, error } = await get('/platform/projects/{ref}/content/folders/{id}', { - params: { path: { ref: projectRef, id: folderId } }, - signal, - }) + const sortOrder = sort === 'name' ? 'asc' : 'desc' - if (error) throw handleError(error) - return data.data - } else { - const { data, error } = await get('/platform/projects/{ref}/content/folders', { - params: { path: { ref: projectRef }, query: { type: 'sql' } }, - signal, - }) + const { data, error } = await get('/platform/projects/{ref}/content/folders', { + params: { + path: { ref: projectRef }, + query: { + type: 'sql', + cursor, + limit: SNIPPET_PAGE_LIMIT.toString(), + sort_by: sort, + sort_order: sortOrder, + name, + // [Alaister] Hard coding visibility to 'user' as folders are only supported for user content + visibility: 'user', + }, + }, + signal, + }) - if (error) throw handleError(error) - return data.data + if (error) throw handleError(error) + return { + ...data.data, + cursor: data.cursor, } } @@ -39,14 +54,21 @@ export type SQLSnippetFoldersData = Awaited( - { projectRef, folderId }: { projectRef?: string; folderId?: string }, + { projectRef, name, sort }: Omit, { enabled = true, ...options - }: UseQueryOptions = {} + }: UseInfiniteQueryOptions = {} ) => - useQuery( - contentKeys.folders(projectRef, folderId), - ({ signal }) => getSQLSnippetFolders({ projectRef, folderId }, signal), - { enabled: enabled && typeof projectRef !== 'undefined', ...options } + useInfiniteQuery( + contentKeys.folders(projectRef, { name, sort }), + ({ signal, pageParam }) => + getSQLSnippetFolders({ projectRef, cursor: pageParam, name, sort }, signal), + { + enabled: enabled && typeof projectRef !== 'undefined', + getNextPageParam(lastPage) { + return lastPage.cursor + }, + ...options, + } ) diff --git a/apps/studio/data/content/sql-snippets-query.ts b/apps/studio/data/content/sql-snippets-query.ts index f90f1000da0..b48a3d39328 100644 --- a/apps/studio/data/content/sql-snippets-query.ts +++ b/apps/studio/data/content/sql-snippets-query.ts @@ -1,25 +1,77 @@ -import { UseQueryOptions } from '@tanstack/react-query' -import { Content, ContentData, ContentError, useContentQuery } from 'data/content/content-query' +import { useInfiniteQuery, UseInfiniteQueryOptions } from '@tanstack/react-query' -export type SqlSnippet = Extract +import { get } from 'data/fetchers' +import { Content } from './content-query' +import { contentKeys } from './keys' +import { SNIPPET_PAGE_LIMIT } from './sql-folders-query' -export type SqlSnippets = { - snippets: SqlSnippet[] +export type SqlSnippet = Extract + +interface GetSqlSnippetsVariables { + projectRef?: string + cursor?: string + visibility?: SqlSnippet['visibility'] + favorite?: boolean + name?: string + sort?: 'name' | 'inserted_at' } -function filterSqlContent(content: Content): content is Extract { - return content.type === 'sql' -} +export async function getSqlSnippets( + { projectRef, cursor, visibility, favorite, name, sort }: GetSqlSnippetsVariables, + signal?: AbortSignal +) { + if (typeof projectRef === 'undefined') { + throw new Error('projectRef is required for getSqlSnippets') + } -export const useSqlSnippetsQuery = ( - projectRef: string | undefined, - options: UseQueryOptions = {} -) => - useContentQuery(projectRef, { - select: (data) => { - return { - snippets: data.content.filter(filterSqlContent), - } + const sortOrder = sort === 'name' ? 'asc' : 'desc' + + const { data, error } = await get('/platform/projects/{ref}/content', { + params: { + path: { ref: projectRef }, + query: { + type: 'sql', + cursor, + visibility, + favorite, + name, + limit: SNIPPET_PAGE_LIMIT.toString(), + sort_by: sort, + sort_order: sortOrder, + }, }, - ...options, + signal, }) + + if (error) { + throw error + } + + return { + cursor: data.cursor, + contents: data.data as unknown as SqlSnippet[], + } +} + +export type SqlSnippetsData = Awaited> +export type SqlSnippetsError = unknown + +export const useSqlSnippetsQuery = ( + { projectRef, sort, name, visibility, favorite }: Omit, + { + enabled = true, + ...options + }: UseInfiniteQueryOptions = {} +) => + useInfiniteQuery( + contentKeys.sqlSnippets(projectRef, { sort, name, visibility, favorite }), + ({ signal, pageParam: cursor }) => + getSqlSnippets({ projectRef, cursor, sort, name, visibility, favorite }, signal), + { + enabled: enabled && typeof projectRef !== 'undefined', + getNextPageParam(lastPage) { + return lastPage.cursor + }, + ...options, + } + ) diff --git a/apps/studio/data/notifications/notifications-v2-query.ts b/apps/studio/data/notifications/notifications-v2-query.ts index 10214be42e6..abba8d02615 100644 --- a/apps/studio/data/notifications/notifications-v2-query.ts +++ b/apps/studio/data/notifications/notifications-v2-query.ts @@ -39,10 +39,10 @@ export async function getNotifications(options: NotificationVariables, signal?: const { status, filters, page = 0, limit = NOTIFICATIONS_PAGE_LIMIT } = options const { data, error } = await get('/platform/notifications', { params: { - // @ts-ignore + // @ts-expect-error maybe the types from the API aren't quite right? query: { - offset: page * limit, - limit, + offset: String(page * limit), + limit: String(limit), ...(status !== undefined ? { status } : { status: ['new', 'seen'] }), ...(filters.priority.length > 0 ? { priority: filters.priority } : {}), ...(filters.organizations.length > 0 ? { org_slug: filters.organizations } : {}), diff --git a/apps/studio/package.json b/apps/studio/package.json index 0cb3c602bd4..643e5c16e83 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -120,7 +120,6 @@ "tus-js-client": "^4.1.0", "ui": "*", "ui-patterns": "*", - "use-debounce": "^7.0.1", "uuid": "^9.0.1", "valtio": "^1.12.0", "vite-tsconfig-paths": "^4.3.2", diff --git a/apps/studio/pages/project/[ref]/logs/explorer/index.tsx b/apps/studio/pages/project/[ref]/logs/explorer/index.tsx index 3a03cedb73e..d06f6fabd4b 100644 --- a/apps/studio/pages/project/[ref]/logs/explorer/index.tsx +++ b/apps/studio/pages/project/[ref]/logs/explorer/index.tsx @@ -307,7 +307,7 @@ export const LogsExplorerPage: NextPageWithLayout = () => { }, onSuccess: (values) => { setSaveModalOpen(false) - toast.success(`Saved "${values[0].name}" log query`) + toast.success(`Saved "${values.name}" log query`) }, }) @@ -320,7 +320,7 @@ export const LogsExplorerPage: NextPageWithLayout = () => { }, onSuccess: (values) => { setSaveModalOpen(false) - toast.success(`Updated "${values[0].name}" log query`) + toast.success(`Updated "${values.name}" log query`) }, }) diff --git a/apps/studio/pages/project/[ref]/logs/explorer/saved.tsx b/apps/studio/pages/project/[ref]/logs/explorer/saved.tsx index 520da4bc8fe..0c4e52837f0 100644 --- a/apps/studio/pages/project/[ref]/logs/explorer/saved.tsx +++ b/apps/studio/pages/project/[ref]/logs/explorer/saved.tsx @@ -12,7 +12,10 @@ import { Loading } from 'ui' export const LogsSavedPage: NextPageWithLayout = () => { const { ref } = useParams() - const { data, isLoading } = useContentQuery(ref) + const { data, isLoading } = useContentQuery({ + projectRef: ref, + type: 'log_sql', + }) if (isLoading) { return {null} diff --git a/apps/studio/pages/project/[ref]/reports/index.tsx b/apps/studio/pages/project/[ref]/reports/index.tsx index 81e08b532af..03cd7dbfd59 100644 --- a/apps/studio/pages/project/[ref]/reports/index.tsx +++ b/apps/studio/pages/project/[ref]/reports/index.tsx @@ -19,15 +19,21 @@ export const UserReportPage: NextPageWithLayout = () => { const { profile } = useProfile() const [showCreateReportModal, setShowCreateReportModal] = useState(false) - const { isLoading } = useContentQuery(ref, { - onSuccess: (data) => { - const reports = data.content - .filter((x) => x.type === 'report') - .sort((a, b) => a.name.localeCompare(b.name)) - if (reports.length >= 1) router.push(`/project/${ref}/reports/${reports[0].id}`) - if (reports.length === 0) router.push(`/project/${ref}/reports/api-overview`) + const { isLoading } = useContentQuery( + { + projectRef: ref, + type: 'report', }, - }) + { + onSuccess: (data) => { + const reports = data.content + .filter((x) => x.type === 'report') + .sort((a, b) => a.name.localeCompare(b.name)) + if (reports.length >= 1) router.push(`/project/${ref}/reports/${reports[0].id}`) + if (reports.length === 0) router.push(`/project/${ref}/reports/api-overview`) + }, + } + ) const canCreateReport = useCheckPermissions(PermissionAction.CREATE, 'user_content', { resource: { type: 'report', owner_id: profile?.id }, diff --git a/apps/studio/pages/project/[ref]/sql/[id].tsx b/apps/studio/pages/project/[ref]/sql/[id].tsx index b627a7237be..4b5c691cd92 100644 --- a/apps/studio/pages/project/[ref]/sql/[id].tsx +++ b/apps/studio/pages/project/[ref]/sql/[id].tsx @@ -17,19 +17,19 @@ import { useFormatQueryMutation } from 'data/sql/format-sql-query' import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' import { LOCAL_STORAGE_KEYS } from 'lib/constants' import { useAppStateSnapshot } from 'state/app-state' -import { useSnippets, useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2' +import { SnippetWithContent, useSnippets, useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2' import type { NextPageWithLayout } from 'types' const SqlEditor: NextPageWithLayout = () => { const router = useRouter() const monaco = useMonaco() - const { id, ref, content } = useParams() + const { id, ref, content, skip } = useParams() const { project } = useProjectContext() const appSnap = useAppStateSnapshot() const snapV2 = useSqlEditorV2StateSnapshot() - const snippets = useSnippets(ref!) + const allSnippets = useSnippets(ref!) const { mutateAsync: formatQuery } = useFormatQueryMutation() const [intellisenseEnabled] = useLocalStorageQuery( @@ -37,22 +37,24 @@ const SqlEditor: NextPageWithLayout = () => { true ) - useContentIdQuery( + // [Refactor] There's an unnecessary request getting triggered when we start typing while on /new + // the URL ID gets updated and we attempt to fetch content for a snippet that's not been created yet + const { data } = useContentIdQuery( { projectRef: ref, id }, { // [Joshen] May need to investigate separately, but occasionally addSnippet doesnt exist in // the snapV2 valtio store for some reason hence why the added typeof check here retry: false, enabled: Boolean(id !== 'new' && typeof snapV2.addSnippet === 'function'), - onSuccess: (data) => { - snapV2.addSnippet({ projectRef: ref as string, snippet: data }) - }, - onError: () => { - // [Joshen] Thinking if we need some error handler - it'll error out here when a new snippet is created from quickstart/templates - }, } ) + useEffect(() => { + if (ref && data) { + snapV2.setSnippet(ref, data as unknown as SnippetWithContent) + } + }, [ref, data]) + async function formatPgsql(value: string) { try { if (!project) throw new Error('No project') @@ -118,12 +120,19 @@ const SqlEditor: NextPageWithLayout = () => { pgInfoRef.current.functions = functions } + // Load the last visited snippet when landing on /new useEffect(() => { - if (id === 'new' && appSnap.dashboardHistory.sql !== undefined && content === undefined) { - const snippet = snippets.find((snippet) => snippet.id === appSnap.dashboardHistory.sql) + if ( + id === 'new' && + skip !== 'true' && // [Joshen] Skip flag implies to skip loading the last visited snippet + appSnap.dashboardHistory.sql !== undefined && + content === undefined + ) { + const snippet = allSnippets.find((snippet) => snippet.id === appSnap.dashboardHistory.sql) if (snippet !== undefined) router.push(`/project/${ref}/sql/${appSnap.dashboardHistory.sql}`) } - }, [id, snippets, content]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [id, allSnippets, content]) // Enable pgsql format useEffect(() => { @@ -138,6 +147,7 @@ const SqlEditor: NextPageWithLayout = () => { }) return () => formatProvider.dispose() } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [monaco]) // Register auto completion item provider for pgsql @@ -162,6 +172,7 @@ const SqlEditor: NextPageWithLayout = () => { signatureHelpProvider?.dispose() } } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [isPgInfoReady]) return ( diff --git a/apps/studio/state/sql-editor-v2.ts b/apps/studio/state/sql-editor-v2.ts index 6e6b7af792a..f5b3d2f72ba 100644 --- a/apps/studio/state/sql-editor-v2.ts +++ b/apps/studio/state/sql-editor-v2.ts @@ -3,15 +3,16 @@ import { toast } from 'sonner' import { proxy, snapshot, subscribe, useSnapshot } from 'valtio' import { devtools, proxySet } from 'valtio/utils' +import { DiffType } from 'components/interfaces/SQLEditor/SQLEditor.types' +import { getContentById } from 'data/content/content-id-query' import { UpsertContentPayloadV2, upsertContent } from 'data/content/content-upsert-v2-mutation' import { contentKeys } from 'data/content/keys' import { createSQLSnippetFolder } from 'data/content/sql-folder-create-mutation' import { updateSQLSnippetFolder } from 'data/content/sql-folder-update-mutation' -import { Snippet, SnippetFolder, SnippetFolderResponse } from 'data/content/sql-folders-query' -import { SqlSnippet } from 'data/content/sql-snippets-query' +import { Snippet, SnippetFolder } from 'data/content/sql-folders-query' import { getQueryClient } from 'data/query-client' -import { getContentById } from 'data/content/content-id-query' -import { DiffType } from 'components/interfaces/SQLEditor/SQLEditor.types' +import { SqlSnippets } from 'types' +import { useMemo } from 'react' export type StateSnippetFolder = { projectRef: string @@ -20,14 +21,14 @@ export type StateSnippetFolder = { } // [Joshen] API codegen is somehow missing the content property -export interface SnippetContent extends Snippet { - content?: any +export interface SnippetWithContent extends Snippet { + content?: SqlSnippets.Content } export type StateSnippet = { projectRef: string splitSizes: number[] - snippet: SnippetContent + snippet: SnippetWithContent } const NEW_FOLDER_ID = 'new-folder' @@ -43,14 +44,7 @@ export const sqlEditorState = proxy({ snippets: {} as { [snippetId: string]: StateSnippet }, - // We're storing favorites separately as they need to be a flat list and hence - // cannot be derived from snippets as folder contents are loaded on demand - favoriteSnippets: {} as { - [snippetId: string]: { - projectRef: string - snippet: SqlSnippet - } - }, + // Query results, if any, for a snippet results: {} as { [snippetId: string]: { @@ -59,13 +53,6 @@ export const sqlEditorState = proxy({ autoLimit?: number }[] }, - // Project ref as the key, marks which project already has snippets loaded - loaded: {} as { - [projectRef: string]: boolean - }, - privateSnippetCount: {} as { - [projectRef: string]: number - }, // Synchronous saving of folders and snippets (debounce behavior) needsSaving: proxySet([]), // Stores the state of each snippet @@ -73,7 +60,6 @@ export const sqlEditorState = proxy({ [snippetId: string]: 'IDLE' | 'UPDATING' | 'UPDATING_FAILED' }, limit: 100, - order: 'inserted_at' as 'name' | 'inserted_at', // For handling renaming folder failed lastUpdatedFolderName: '', @@ -88,58 +74,15 @@ export const sqlEditorState = proxy({ // ## Methods to interact the store with // ======================================================================== - // Initial loading of data into UI, only called once when first loading data - // Note that snippets here do not have the content property, and will need to be - // further loaded on demand instead. Entry point from SQLEditorNav.tsx - initializeRemoteSnippets: ({ - projectRef, - data, - sort, - }: { - projectRef: string - data: SnippetFolderResponse - sort: 'name' | 'inserted_at' - }) => { - const { folders, contents } = data - folders?.forEach((folder) => { - sqlEditorState.folders[folder.id] = { projectRef, folder } - }) - contents?.forEach((snippet) => { - sqlEditorState.addSnippet({ projectRef, snippet }) - }) - sqlEditorState.loaded[projectRef] = true - sqlEditorState.order = sort - }, - - initializeFavoriteSnippets: ({ - projectRef, - snippets, - }: { - projectRef: string - snippets: SqlSnippet[] - }) => { - snippets.forEach((snippet) => { - if (snippet.id && sqlEditorState.favoriteSnippets[snippet.id]?.snippet === undefined) { - sqlEditorState.favoriteSnippets[snippet.id] = { projectRef, snippet } - } - }) - }, - setDiffContent: (sql: string, diffType: DiffType) => (sqlEditorState.diffContent = { sql, diffType }), - setOrder: (value: 'name' | 'inserted_at') => (sqlEditorState.order = value), + addSnippet: ({ projectRef, snippet }: { projectRef: string; snippet: SnippetWithContent }) => { + if (sqlEditorState.snippets[snippet.id]) return - setPrivateSnippetCount: ({ projectRef, value }: { projectRef: string; value: number }) => { - sqlEditorState.privateSnippetCount[projectRef] = value - }, - - addSnippet: ({ projectRef, snippet }: { projectRef: string; snippet: Snippet }) => { - if (snippet.id && sqlEditorState.snippets[snippet.id]?.snippet?.content === undefined) { - sqlEditorState.snippets[snippet.id] = { projectRef, splitSizes: [50, 50], snippet } - sqlEditorState.results[snippet.id] = [] - sqlEditorState.savingStates[snippet.id] = 'IDLE' - } + sqlEditorState.snippets[snippet.id] = { projectRef, splitSizes: [50, 50], snippet } + sqlEditorState.results[snippet.id] = [] + sqlEditorState.savingStates[snippet.id] = 'IDLE' }, updateSnippet: ({ @@ -148,7 +91,7 @@ export const sqlEditorState = proxy({ skipSave = false, }: { id: string - snippet: Snippet + snippet: Partial skipSave?: boolean }) => { if (sqlEditorState.snippets[id]) { @@ -160,9 +103,22 @@ export const sqlEditorState = proxy({ } }, + setSnippet: (projectRef: string, snippet: SnippetWithContent) => { + let storedSnippet = sqlEditorState.snippets[snippet.id] + if (storedSnippet) { + if (!storedSnippet.snippet.content) { + storedSnippet.snippet.content = snippet.content + sqlEditorState.needsSaving.add(storedSnippet.snippet.id) + } + } else { + sqlEditorState.addSnippet({ projectRef: projectRef, snippet }) + } + }, + setSql: (id: string, sql: string) => { - if (sqlEditorState.snippets[id]) { - sqlEditorState.snippets[id].snippet.content.sql = sql + let snippet = sqlEditorState.snippets[id]?.snippet + if (snippet?.content) { + snippet.content.sql = sql sqlEditorState.needsSaving.add(id) } }, @@ -176,12 +132,10 @@ export const sqlEditorState = proxy({ name: string description?: string }) => { - if (sqlEditorState.snippets[id]) { - sqlEditorState.snippets[id] = { - ...sqlEditorState.snippets[id], - snippet: { ...sqlEditorState.snippets[id].snippet, name, description }, - } - sqlEditorState.needsSaving.add(id) + let snippet = sqlEditorState.snippets[id]?.snippet + if (snippet) { + snippet.name = name + snippet.description = description } }, @@ -195,6 +149,12 @@ export const sqlEditorState = proxy({ sqlEditorState.needsSaving.delete(id) }, + addFolder: ({ projectRef, folder }: { projectRef: string; folder: SnippetFolder }) => { + if (sqlEditorState.folders[folder.id]) return + + sqlEditorState.folders[folder.id] = { projectRef, folder } + }, + addNewFolder: ({ projectRef }: { projectRef: string }) => { // [Joshen] Use this to identify new folders that have yet to be saved const id = NEW_FOLDER_ID @@ -212,31 +172,27 @@ export const sqlEditorState = proxy({ }, editFolder: (id: string) => { - sqlEditorState.folders[id] = { ...sqlEditorState.folders[id], status: 'editing' } + sqlEditorState.folders[id].status = 'editing' }, saveFolder: ({ id, name }: { id: string; name: string }) => { - const hasChanges = sqlEditorState.folders[id].folder.name !== name + let storeFolder = sqlEditorState.folders[id] + const hasChanges = storeFolder.folder.name !== name if (id === 'new-folder' && sqlEditorState.allFolderNames.includes(name)) { sqlEditorState.removeFolder(id) return toast.error('This folder name already exists') } else if (hasChanges && sqlEditorState.allFolderNames.includes(name)) { - sqlEditorState.folders[id] = { ...sqlEditorState.folders[id], status: 'idle' } + storeFolder.status = 'idle' return toast.error('This folder name already exists') } - const originalFolderName = sqlEditorState.folders[id].folder.name.slice() + const originalFolderName = storeFolder.folder.name.slice() + + storeFolder.status = hasChanges ? 'saving' : 'idle' + storeFolder.folder.id = id + storeFolder.folder.name = name - sqlEditorState.folders[id] = { - projectRef: sqlEditorState.folders[id].projectRef, - status: hasChanges ? 'saving' : 'idle', - folder: { - ...sqlEditorState.folders[id].folder, - id, - name, - }, - } if (hasChanges) { sqlEditorState.lastUpdatedFolderName = originalFolderName sqlEditorState.needsSaving.add(id) @@ -253,78 +209,18 @@ export const sqlEditorState = proxy({ addNeedsSaving: (id: string) => sqlEditorState.needsSaving.add(id), addFavorite: (id: string) => { - if (sqlEditorState.snippets[id]) { - sqlEditorState.snippets[id] = { - ...sqlEditorState.snippets[id], - snippet: { - ...sqlEditorState.snippets[id].snippet, - favorite: true, - }, - } - - sqlEditorState.favoriteSnippets[id] = { - projectRef: sqlEditorState.snippets[id].projectRef, - snippet: { - ...sqlEditorState.snippets[id].snippet, - folder_id: undefined, - } as unknown as SqlSnippet, - } + const storeSnippet = sqlEditorState.snippets[id] + if (storeSnippet) { + storeSnippet.snippet.favorite = true sqlEditorState.needsSaving.add(id) } }, removeFavorite: (id: string) => { - if (sqlEditorState.snippets[id]) { - sqlEditorState.snippets[id] = { - ...sqlEditorState.snippets[id], - snippet: { - ...sqlEditorState.snippets[id].snippet, - favorite: false, - }, - } - - const { [id]: snippet, ...otherSnippets } = sqlEditorState.favoriteSnippets - sqlEditorState.favoriteSnippets = otherSnippets - - sqlEditorState.needsSaving.add(id) - } - }, - - shareSnippet: async (id: string, visibility: 'user' | 'project' | 'org' | 'public') => { const storeSnippet = sqlEditorState.snippets[id] - - if (storeSnippet) { - let snippetContent = storeSnippet.snippet.content - if (snippetContent === undefined) { - const { content } = await getContentById({ projectRef: storeSnippet.projectRef, id }) - snippetContent = content - } - - if (snippetContent === undefined) { - // [Joshen] Just as a final check - to ensure that the content is minimally there (empty string is fine) - return toast.error('Unable to share snippet: Content is missing') - } - - sqlEditorState.snippets[id] = { - ...storeSnippet, - snippet: { - ...storeSnippet.snippet, - content: snippetContent, - visibility, - folder_id: null as any, - }, - } - - if (sqlEditorState.favoriteSnippets[id] !== undefined) { - sqlEditorState.favoriteSnippets[id] = { - projectRef: sqlEditorState.favoriteSnippets[id].projectRef, - snippet: { - ...sqlEditorState.favoriteSnippets[id].snippet, - visibility, - } as unknown as SqlSnippet, - } - } + if (storeSnippet.snippet) { + storeSnippet.snippet.favorite = false sqlEditorState.needsSaving.add(id) } @@ -361,33 +257,28 @@ export const useSqlEditorV2StateSnapshot = (options?: Parameters { const snapshot = useSqlEditorV2StateSnapshot() - return Object.values(snapshot.folders) - .filter((x) => x.projectRef === projectRef) - .map((x) => x.folder) + return useMemo( + () => + Object.values(snapshot.folders) + .filter((x) => x.projectRef === projectRef) + .map((x) => x.folder), + [projectRef, snapshot.folders] + ) } +/** + * Get ALL snippets for a project + */ export const useSnippets = (projectRef: string) => { const snapshot = useSqlEditorV2StateSnapshot() - return Object.values(snapshot.snippets) - .filter((x) => x.projectRef === projectRef) - .map((x) => x.snippet) - .sort((a, b) => { - if (snapshot.order === 'name') return a.name.localeCompare(b.name) - else return new Date(b.inserted_at).valueOf() - new Date(a.inserted_at).valueOf() - }) -} - -export const useFavoriteSnippets = (projectRef: string) => { - const snapshot = useSqlEditorV2StateSnapshot() - - return Object.values(snapshot.favoriteSnippets) - .filter((x) => x.projectRef === projectRef) - .map((x) => x.snippet) - .sort((a, b) => { - if (snapshot.order === 'name') return a.name.localeCompare(b.name) - else return new Date(b.inserted_at ?? '').valueOf() - new Date(a.inserted_at ?? '').valueOf() - }) + return useMemo( + () => + Object.values(snapshot.snippets) + .filter((storeSnippet) => storeSnippet.projectRef === projectRef) + .map((storeSnippet) => storeSnippet.snippet), + [projectRef, snapshot.snippets] + ) } // ======================================================================== @@ -400,7 +291,11 @@ async function upsertSnippet(id: string, projectRef: string, payload: UpsertCont await upsertContent({ projectRef, payload }) const queryClient = getQueryClient() - await queryClient.invalidateQueries(contentKeys.count(projectRef, 'sql')) + await Promise.all([ + queryClient.invalidateQueries(contentKeys.count(projectRef, 'sql')), + queryClient.invalidateQueries(contentKeys.sqlSnippets(projectRef)), + queryClient.invalidateQueries(contentKeys.folders(projectRef)), + ]) sqlEditorState.savingStates[id] = 'IDLE' } catch (error) { @@ -423,7 +318,7 @@ async function upsertFolder(id: string, projectRef: string, name: string) { } else { await updateSQLSnippetFolder({ projectRef, id, name }) toast.success('Successfully updated folder') - sqlEditorState.folders[id] = { ...sqlEditorState.folders[id], status: 'idle' } + sqlEditorState.folders[id].status = 'idle' } } catch (error: any) { toast.error(`Failed to save folder: ${error.message}`) @@ -433,14 +328,10 @@ async function upsertFolder(id: string, projectRef: string, name: string) { error.message.includes('update') && sqlEditorState.lastUpdatedFolderName.length > 0 ) { - sqlEditorState.folders[id] = { - ...sqlEditorState.folders[id], - status: 'idle', - folder: { - ...sqlEditorState.folders[id].folder, - name: sqlEditorState.lastUpdatedFolderName, - }, - } + let storeFolder = sqlEditorState.folders[id] + + storeFolder.status = 'idle' + storeFolder.folder.name = sqlEditorState.lastUpdatedFolderName } } finally { sqlEditorState.lastUpdatedFolderName = '' @@ -484,11 +375,11 @@ if (typeof window !== 'undefined') { visibility: visibility ?? 'user', project_id: project_id ?? 0, owner_id: owner_id, - folder_id: folder_id, + folder_id: folder_id ?? undefined, content: { - ...content, + ...content!, content_id: id, - favorite: favorite, + favorite: favorite ?? false, }, }) sqlEditorState.needsSaving.delete(id) diff --git a/package-lock.json b/package-lock.json index 42529488a4e..212f476c78f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1752,7 +1752,6 @@ "tus-js-client": "^4.1.0", "ui": "*", "ui-patterns": "*", - "use-debounce": "^7.0.1", "uuid": "^9.0.1", "valtio": "^1.12.0", "vite-tsconfig-paths": "^4.3.2", diff --git a/packages/api-types/types/api.d.ts b/packages/api-types/types/api.d.ts index afdcc0f3652..1cf709bab4e 100644 --- a/packages/api-types/types/api.d.ts +++ b/packages/api-types/types/api.d.ts @@ -619,14 +619,6 @@ export interface paths { /** Searches project pg.tables. Return maximum 50 results. */ post: operations['SearchController_searchTables'] } - '/platform/pg-meta/{ref}/table-privileges': { - /** Retrieve table privileges */ - get: operations['TablePrivilegesController_getTablePrivileges'] - /** Grant table privileges */ - post: operations['TablePrivilegesController_grantTablePrivileges'] - /** Revoke table privileges */ - delete: operations['TablePrivilegesController_revokeTablePrivileges'] - } '/platform/pg-meta/{ref}/tables': { /** Gets project pg.tables or pg.table with the given ID */ get: operations['TablesController_getTables'] @@ -693,10 +685,6 @@ export interface paths { /** Search profiles by username, email with the given keywords */ post: operations['SearchProfileController_searchProfile'] } - '/platform/profile/subscriptions': { - /** Gets the user's subscription statistics */ - get: operations['SubscriptionsController_getSubscriptionsStatistics'] - } '/platform/projects': { /** * Gets all projects that belong to the authenticated user @@ -866,15 +854,12 @@ export interface paths { patch: operations['v1-update-supavisor-config'] } '/platform/projects/{ref}/content': { - /** - * Gets project's content - * @deprecated - */ + /** Gets project's content */ get: operations['ContentController_getContent'] /** Updates project's content */ - put: operations['ContentController_updateWholeContentV2'] + put: operations['ContentController_updateWholeContent'] /** Creates project's content */ - post: operations['ContentController_createContentV2'] + post: operations['ContentController_createContent'] /** Deletes project's contents */ delete: operations['ContentController_deleteContents'] /** @@ -884,7 +869,7 @@ export interface paths { patch: operations['ContentController_updateContent'] } '/platform/projects/{ref}/content/count': { - /** Gets the count of a user's content by type */ + /** Gets the count of a user's content */ get: operations['ContentController_getContentCount'] } '/platform/projects/{ref}/content/folders': { @@ -1685,14 +1670,6 @@ export interface paths { /** Searches project pg.tables. Return maximum 50 results. */ post: operations['SearchController_searchTables'] } - '/v0/pg-meta/{ref}/table-privileges': { - /** Retrieve table privileges */ - get: operations['TablePrivilegesController_getTablePrivileges'] - /** Grant table privileges */ - post: operations['TablePrivilegesController_grantTablePrivileges'] - /** Revoke table privileges */ - delete: operations['TablePrivilegesController_revokeTablePrivileges'] - } '/v0/pg-meta/{ref}/tables': { /** Gets project pg.tables or pg.table with the given ID */ get: operations['TablesController_getTables'] @@ -1883,15 +1860,12 @@ export interface paths { patch: operations['v1-update-supavisor-config'] } '/v0/projects/{ref}/content': { - /** - * Gets project's content - * @deprecated - */ + /** Gets project's content */ get: operations['ContentController_getContent'] /** Updates project's content */ - put: operations['ContentController_updateWholeContentV2'] + put: operations['ContentController_updateWholeContent'] /** Creates project's content */ - post: operations['ContentController_createContentV2'] + post: operations['ContentController_createContent'] /** Deletes project's contents */ delete: operations['ContentController_deleteContents'] /** @@ -1901,7 +1875,7 @@ export interface paths { patch: operations['ContentController_updateContent'] } '/v0/projects/{ref}/content/count': { - /** Gets the count of a user's content by type */ + /** Gets the count of a user's content */ get: operations['ContentController_getContentCount'] } '/v0/projects/{ref}/content/item/{id}': { @@ -2956,9 +2930,13 @@ export interface components { tableId: number type: string } - CreateContentBody: { - content?: Record + CreateContentBodyDto: { + content?: { + [key: string]: unknown + } description?: string + /** Format: uuid */ + folder_id?: null | string id?: string name: string owner_id?: number @@ -2967,20 +2945,9 @@ export interface components { /** @enum {string} */ visibility: 'user' | 'project' | 'org' | 'public' } - CreateContentBodyV2: { - content?: Record - description?: string - folder_id?: string - id?: string - name: string - owner_id?: number - /** @enum {string} */ - type: 'sql' | 'report' | 'log_sql' - /** @enum {string} */ - visibility: 'user' | 'project' | 'org' | 'public' - } - CreateContentFolderBody: { + CreateContentFolderBodyDto: { name: string + /** Format: uuid */ parent_id?: string } CreateExtensionBody: { @@ -3794,7 +3761,7 @@ export interface components { content: Record description?: string favorite: boolean | null - folder_id?: string + folder_id?: string | null id: string inserted_at: string last_updated_by?: number @@ -3808,6 +3775,7 @@ export interface components { visibility: 'user' | 'project' | 'org' | 'public' } GetUserContentFolderResponse: { + cursor?: string data: { contents?: components['schemas']['UserContentObjectMeta'][] folders?: components['schemas']['UserContentFolder'][] @@ -3816,6 +3784,8 @@ export interface components { GetUserContentObject: { content: Record description?: string + favorite?: boolean | null + folder_id?: string | null id: string inserted_at: string last_updated_by?: number @@ -3837,6 +3807,7 @@ export interface components { visibility: 'user' | 'project' | 'org' | 'public' } GetUserContentResponse: { + cursor?: string data: components['schemas']['GetUserContentObject'][] } GetUserOrganizationIntegrationResponse: { @@ -4056,21 +4027,6 @@ export interface components { /** @enum {string} */ privilege_type: 'ALL' | 'SELECT' | 'INSERT' | 'UPDATE' | 'REFERENCES' } - GrantTablePrivilegesBody: { - grantee: string - is_grantable?: boolean - /** @enum {string} */ - privilege_type: - | 'ALL' - | 'SELECT' - | 'INSERT' - | 'UPDATE' - | 'DELETE' - | 'TRUNCATE' - | 'REFERENCES' - | 'TRIGGER' - relation_id: number - } HCaptchaBody: { hcaptchaToken: string } @@ -4813,12 +4769,6 @@ export interface components { schema: string size: string } - PostgresTablePrivileges: { - kind: string - name: string - privileges: components['schemas']['TablePrivilege'][] - schema: string - } PostgrestConfigResponse: { db_anon_role: string db_extra_search_path: string @@ -5177,12 +5127,8 @@ export interface components { region: string service_api_keys?: components['schemas']['ProjectServiceApiKeyResponse'][] ssl_enforced: boolean - is_sensitive?: boolean status: string } - ProjectSensitivitySettingResponse: { - is_sensitive: boolean - } /** @enum {string} */ ProjectStatus: | 'ACTIVE_HEALTHY' @@ -5458,20 +5404,6 @@ export interface components { /** @enum {string} */ privilege_type: 'ALL' | 'SELECT' | 'INSERT' | 'UPDATE' | 'REFERENCES' } - RevokeTablePrivilegesBody: { - grantee: string - /** @enum {string} */ - privilege_type: - | 'ALL' - | 'SELECT' - | 'INSERT' - | 'UPDATE' - | 'DELETE' - | 'TRUNCATE' - | 'REFERENCES' - | 'TRIGGER' - relation_id: number - } RunQueryBody: { query: string } @@ -5674,6 +5606,7 @@ export interface components { sql: string } SnippetList: { + cursor?: string data: components['schemas']['SnippetMeta'][] } SnippetMeta: { @@ -5782,16 +5715,6 @@ export interface components { SubdomainAvailabilityResponse: { available: boolean } - SubscriptionStatisticsResponse: { - total_active_free_projects: number - total_enterprise_projects: number - total_free_projects: number - total_paid_projects: number - total_paused_free_projects: number - total_payg_projects: number - total_pro_projects: number - total_team_projects: number - } SupavisorConfigResponse: { connectionString: string /** @enum {string} */ @@ -5883,22 +5806,8 @@ export interface components { name: string schema: string } - TablePrivilege: { - grantee: string - grantor: string - is_grantable: boolean - /** @enum {string} */ - privilege_type: - | 'ALL' - | 'SELECT' - | 'INSERT' - | 'UPDATE' - | 'DELETE' - | 'TRUNCATE' - | 'REFERENCES' - | 'TRIGGER' - } TargetClonedProject: { + name: string ref: string } TargetCloneStatus: { @@ -6241,9 +6150,13 @@ export interface components { name?: string type?: string } - UpdateContentBody: { - content?: Record + UpdateContentBodyDto: { + content?: { + [key: string]: unknown + } description?: string + /** Format: uuid */ + folder_id?: null | string id?: string name?: string owner_id?: number @@ -6252,7 +6165,7 @@ export interface components { /** @enum {string} */ visibility?: 'user' | 'project' | 'org' | 'public' } - UpdateContentFolderBody: { + UpdateContentFolderBodyDto: { name: string } UpdateCustomHostnameBody: { @@ -6598,9 +6511,6 @@ export interface components { UpdateProjectBody: { name: string } - UpdateProjectSensitivityBody: { - is_sensitive: boolean - } UpdateProviderBody: { attribute_mapping?: components['schemas']['AttributeMapping'] domains?: string[] @@ -6782,25 +6692,16 @@ export interface components { release_channel: components['schemas']['ReleaseChannel'] target_version: string } - UpsertContentBody: { - content?: Record + UpsertContentBodyDto: { + content?: { + [key: string]: unknown + } description?: string - id?: string + /** Format: uuid */ + folder_id?: null | string + id: string name: string - owner_id?: number - project_id?: number - /** @enum {string} */ - type: 'sql' | 'report' | 'log_sql' - /** @enum {string} */ - visibility: 'user' | 'project' | 'org' | 'public' - } - UpsertContentBodyV2: { - content?: Record - description?: string - folder_id?: string - id?: string - name: string - owner_id?: number + owner_id: number project_id?: number /** @enum {string} */ type: 'sql' | 'report' | 'log_sql' @@ -6862,6 +6763,8 @@ export interface components { UserContentObject: { content: Record description?: string + favorite?: boolean | null + folder_id?: string | null id: string inserted_at: string last_updated_by?: number @@ -6876,25 +6779,8 @@ export interface components { } UserContentObjectMeta: { description?: string - favorite: boolean | null - folder_id?: string - id: string - inserted_at: string - last_updated_by?: number - name: string - owner_id: number - project_id: number - /** @enum {string} */ - type: 'sql' | 'report' | 'log_sql' - updated_at: string - /** @enum {string} */ - visibility: 'user' | 'project' | 'org' | 'public' - } - UserContentObjectV2: { - content: Record - description?: string - favorite: boolean | null - folder_id?: string + favorite?: boolean | null + folder_id?: string | null id: string inserted_at: string last_updated_by?: number @@ -8650,13 +8536,13 @@ export interface operations { /** Get notifications */ NotificationsController_getNotificationsV2: { parameters: { - query: { - status: 'new' | 'seen' | 'archived' - priority: 'Critical' | 'Warning' | 'Info' - org_slug?: string[] - project_ref?: string[] - offset: number - limit: number + query?: { + limit?: string + offset?: string + priority?: 'Critical' | 'Warning' | 'Info' + status?: 'new' | 'seen' | 'archived' + org_slug?: string + project_ref?: string } } responses: { @@ -11189,94 +11075,6 @@ export interface operations { } } } - /** Retrieve table privileges */ - TablePrivilegesController_getTablePrivileges: { - parameters: { - header: { - 'x-connection-encrypted': string - } - path: { - /** @description Project ref */ - ref: string - } - } - responses: { - 200: { - content: { - 'application/json': components['schemas']['PostgresTablePrivileges'][] - } - } - 403: { - content: never - } - /** @description Failed to retrieve table privileges */ - 500: { - content: never - } - } - } - /** Grant table privileges */ - TablePrivilegesController_grantTablePrivileges: { - parameters: { - header: { - 'x-connection-encrypted': string - } - path: { - /** @description Project ref */ - ref: string - } - } - requestBody: { - content: { - 'application/json': components['schemas']['GrantTablePrivilegesBody'][] - } - } - responses: { - 201: { - content: { - 'application/json': components['schemas']['PostgresTablePrivileges'][] - } - } - 403: { - content: never - } - /** @description Failed to grant table privileges */ - 500: { - content: never - } - } - } - /** Revoke table privileges */ - TablePrivilegesController_revokeTablePrivileges: { - parameters: { - header: { - 'x-connection-encrypted': string - } - path: { - /** @description Project ref */ - ref: string - } - } - requestBody: { - content: { - 'application/json': components['schemas']['RevokeTablePrivilegesBody'][] - } - } - responses: { - 200: { - content: { - 'application/json': components['schemas']['PostgresTablePrivileges'][] - } - } - 403: { - content: never - } - /** @description Failed to revoke table privileges */ - 500: { - content: never - } - } - } /** Gets project pg.tables or pg.table with the given ID */ TablesController_getTables: { parameters: { @@ -11811,20 +11609,6 @@ export interface operations { } } } - /** Gets the user's subscription statistics */ - SubscriptionsController_getSubscriptionsStatistics: { - responses: { - 200: { - content: { - 'application/json': components['schemas']['SubscriptionStatisticsResponse'] - } - } - /** @description Failed to retrieve user's subscription statistics */ - 500: { - content: never - } - } - } /** * Gets all projects that belong to the authenticated user * @description Only returns the minimal project info @@ -12910,14 +12694,20 @@ export interface operations { } } } - /** - * Gets project's content - * @deprecated - */ + /** Gets project's content */ ContentController_getContent: { parameters: { + query?: { + cursor?: string + limit?: string + sort_by?: 'name' | 'inserted_at' + sort_order?: 'asc' | 'desc' + visibility?: string + type?: 'sql' | 'report' | 'log_sql' + name?: string + favorite?: boolean + } path: { - /** @description Project ref */ ref: string } } @@ -12934,16 +12724,15 @@ export interface operations { } } /** Updates project's content */ - ContentController_updateWholeContentV2: { + ContentController_updateWholeContent: { parameters: { path: { - /** @description Project ref */ ref: string } } requestBody: { content: { - 'application/json': components['schemas']['UpsertContentBodyV2'] + 'application/json': components['schemas']['UpsertContentBodyDto'] } } responses: { @@ -12957,22 +12746,21 @@ export interface operations { } } /** Creates project's content */ - ContentController_createContentV2: { + ContentController_createContent: { parameters: { path: { - /** @description Project ref */ ref: string } } requestBody: { content: { - 'application/json': components['schemas']['CreateContentBodyV2'] + 'application/json': components['schemas']['CreateContentBodyDto'] } } responses: { 201: { content: { - 'application/json': components['schemas']['UserContentObjectV2'] + 'application/json': components['schemas']['UserContentObject'] } } /** @description Failed to create project's content */ @@ -12988,7 +12776,6 @@ export interface operations { ids: string[] } path: { - /** @description Project ref */ ref: string } } @@ -13013,10 +12800,13 @@ export interface operations { query: { id: string } + path: { + ref: string + } } requestBody: { content: { - 'application/json': components['schemas']['UpdateContentBody'] + 'application/json': components['schemas']['UpdateContentBodyDto'] } } responses: { @@ -13031,14 +12821,16 @@ export interface operations { } } } - /** Gets the count of a user's content by type */ + /** Gets the count of a user's content */ ContentController_getContentCount: { parameters: { query?: { - type?: string + visibility?: string + type?: 'sql' | 'report' | 'log_sql' + favorite?: boolean + name?: string } path: { - /** @description Project ref */ ref: string } } @@ -13058,10 +12850,15 @@ export interface operations { ContentFoldersController_getRootFolder: { parameters: { query?: { + cursor?: string + limit?: string + sort_by?: 'name' | 'inserted_at' + sort_order?: 'asc' | 'desc' + visibility?: string type?: 'sql' | 'report' | 'log_sql' + name?: string } path: { - /** @description Project ref */ ref: string } } @@ -13081,13 +12878,12 @@ export interface operations { ContentFoldersController_createFolder: { parameters: { path: { - /** @description Project ref */ ref: string } } requestBody: { content: { - 'application/json': components['schemas']['CreateContentFolderBody'] + 'application/json': components['schemas']['CreateContentFolderBodyDto'] } } responses: { @@ -13108,6 +12904,9 @@ export interface operations { query: { ids: string[] } + path: { + ref: string + } } responses: { 200: { @@ -13122,11 +12921,17 @@ export interface operations { /** Gets project's content folder */ ContentFoldersController_getFolder: { parameters: { + query?: { + cursor?: string + limit?: string + sort_by?: 'name' | 'inserted_at' + sort_order?: 'asc' | 'desc' + name?: string + } path: { - /** @description Project ref */ - ref: string /** @description Content folder id */ id: string + ref: string } } responses: { @@ -13145,15 +12950,14 @@ export interface operations { ContentFoldersController_updateFolder: { parameters: { path: { - /** @description Project ref */ - ref: string /** @description Content folder id */ id: string + ref: string } } requestBody: { content: { - 'application/json': components['schemas']['UpdateContentFolderBody'] + 'application/json': components['schemas']['UpdateContentFolderBodyDto'] } } responses: { @@ -13170,10 +12974,8 @@ export interface operations { ContentController_getContentById: { parameters: { path: { - /** @description Project ref */ - ref: string - /** @description Content id */ id: string + ref: string } } responses: { @@ -13749,30 +13551,29 @@ export interface operations { } } } - /** Update project's sensitivity settings */ - SettingsController_patchProjectSensitivity: { + /** Updates the given project sensitivity */ + SensitivityController_updateProjectSensitivity: { parameters: { path: { /** @description Project ref */ ref: string - } + } } requestBody: { content: { - 'application/json': components['schemas']['UpdateProjectSensitivityBody'] + 'application/json': components['schemas']['MarkSensitiveBody'] } } responses: { 200: { content: { - 'application/json': components['schemas']['ProjectSensitivitySettingResponse'] + 'application/json': components['schemas']['ProjectSensitivityResponse'] } } - /** @description Failed to update project's sensitivity setting */ - 404: { + 403: { content: never } - /** @description Failed to update project's sensitivity setting */ + /** @description Failed to update project */ 500: { content: never } @@ -17619,6 +17420,10 @@ export interface operations { 'v1-list-all-snippets': { parameters: { query?: { + cursor?: string + limit?: string + sort_by?: 'name' | 'inserted_at' + sort_order?: 'asc' | 'desc' project_ref?: string } } diff --git a/packages/ui-patterns/InnerSideMenu/index.tsx b/packages/ui-patterns/InnerSideMenu/index.tsx index 29e310b08de..fc42d398ce0 100644 --- a/packages/ui-patterns/InnerSideMenu/index.tsx +++ b/packages/ui-patterns/InnerSideMenu/index.tsx @@ -1,4 +1,4 @@ -import { ChevronRight, ChevronsDown, Search } from 'lucide-react' +import { ChevronRight, ChevronsDown, Loader2, Search } from 'lucide-react' import Link from 'next/link' import { ElementRef, forwardRef } from 'react' import { @@ -136,8 +136,12 @@ const InnerSideBarFilters = forwardRef & { 'aria-labelledby': string; name: string } ->(({ children, ...props }, ref) => { + React.ComponentPropsWithoutRef & { + 'aria-labelledby': string + name: string + isLoading?: boolean + } +>(({ children, isLoading = false, ...props }, ref) => { return ( ) }) @@ -251,10 +268,10 @@ const InnerSideBarEmptyPanel = forwardRef< export { InnerSideBarEmptyPanel, - InnerSideBarFilters, InnerSideBarFilterSearchInput, InnerSideBarFilterSortDropdown, InnerSideBarFilterSortDropdownItem, + InnerSideBarFilters, InnerSideBarShimmeringLoaders, InnerSideBarTitle, InnerSideMenuCollapsible,