chore: paginate user content (#30675)

* chore: paginate user content

* progress

* loading states

* add load more buttons to private snippets

* working pagination

* fix some types

* always show snippet counts

* support new api parameters

* favorite snippets

* progress

* searching

* paginate root folder

* fix renaming snippets

* fix ts

* removed unused prop

* Shift sharing/unsharing query logic outside of valtio to leverage on RQ only

* Fix invalidation on an unsaved snippet

* Clean up

* Fix

* Clean up

* Update API type

* Update API

* fix duplicate snippets error after moving a snippet

* add currently selected snippet

* Fix unsharing a snippet that has yet to be opened

* i'm dumb

* fix sharing a snippet

* fix sharing and unsharing

* show favorite or shared snippet in list even if it's in another page

* Fix wrong import for debounce

* Fix false positive toast error when creating custom report

* Update API type

* Change create new snippet CTA to link back to /new with skip flag

* Fix saving logs explorer query

* Bump page number

---------

Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
This commit is contained in:
Alaister YoungandJoshen Lim authored and GitHub committed 2024-12-19 16:08:14 +08:00
1 parent f25ef1e4d5
commit f6d9617413
42 files changed
+1277 -1009

No files matched your search

@@ -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 {
@@ -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()
},
@@ -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,
@@ -34,7 +34,10 @@ const Reports = () => {
const [endDate, setEndDate] = useState<any>(null)
const [hasEdits, setHasEdits] = useState<any>(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)
@@ -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,
@@ -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,
})
})
@@ -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}`)
}
}
@@ -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) {
@@ -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,
@@ -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<ContentData>(
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<ContentData>(
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 (
@@ -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
}
@@ -17,6 +17,7 @@ interface SavedQueriesItemProps {
id: string
name: string
description?: string
owner_id: number
content: {
sql: string
}
@@ -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')
@@ -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) => (
<ReportMenuItem
key={item.id}
item={item}
item={item as any}
pageKey={pageKey}
onSelectEdit={() => {
setSelectedReportToUpdate(item.report)
@@ -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}
>
<InnerSideBarFilterSortDropdown
value={snapV2.order}
onValueChange={(value: any) => snapV2.setOrder(value)}
value={sort}
onValueChange={(value: any) => setSort(value)}
>
<InnerSideBarFilterSortDropdownItem key="name" value="name">
Alphabetical
@@ -141,7 +133,7 @@ export const SQLEditorMenu = ({ onViewOngoingQueries }: SQLEditorMenuProps) => {
</InnerSideMenuItem>
</div>
<SQLEditorNavV2 searchText={searchText} />
<SQLEditorNavV2 searchText={searchText} sort={sort} setIsSearching={setIsSearching} />
</div>
<div className="p-4 border-t sticky bottom-0 bg-studio">
@@ -0,0 +1,30 @@
import { Skeleton } from 'ui'
const SQLEditorLoadingSnippets = () => {
return (
<>
<div className="flex flex-row h-6 px-3 items-center gap-3">
<Skeleton className="h-4 w-5" />
<Skeleton className="w-40 h-4" />
</div>
<div className="flex flex-row h-6 px-3 items-center gap-3">
<Skeleton className="h-4 w-5" />
<Skeleton className="w-32 h-4" />
</div>
<div className="flex flex-row h-6 px-3 items-center gap-3 opacity-75">
<Skeleton className="h-4 w-5" />
<Skeleton className="w-20 h-4" />
</div>
<div className="flex flex-row h-6 px-3 items-center gap-3 opacity-50">
<Skeleton className="h-4 w-5" />
<Skeleton className="w-40 h-4" />
</div>
<div className="flex flex-row h-6 px-3 items-center gap-3 opacity-25">
<Skeleton className="h-4 w-5" />
<Skeleton className="w-20 h-4" />
</div>
</>
)
}
export default SQLEditorLoadingSnippets
@@ -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<SetStateAction<boolean>>
}
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<Snippet>()
const [selectedFolderToDelete, setSelectedFolderToDelete] = useState<SnippetFolder>()
// =======================================================
// [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<string>
},
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<string>([...acc.snippetIds, ...newSnippetIds]),
}
},
{
snippets: rootSnippets,
isLoading: isLoading || (isPreviousData && isFetching),
snippetIds: new Set<string>(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 (
<>
<InnerSideMenuSeparator />
{((numProjectSnippets === 0 && searchText.length === 0) || numProjectSnippets > 0) && (
<>
<InnerSideMenuCollapsible
open={showSharedSnippets}
onOpenChange={setShowSharedSnippets}
className="px-0"
>
<InnerSideMenuCollapsibleTrigger
title={`Shared ${numProjectSnippets > 0 ? ` (${numProjectSnippets})` : ''}`}
<InnerSideMenuCollapsible
open={showSharedSnippets}
onOpenChange={setShowSharedSnippets}
className="px-0"
>
<InnerSideMenuCollapsibleTrigger
title={`Shared ${numProjectSnippets > 0 ? ` (${numProjectSnippets})` : ''}`}
/>
<InnerSideMenuCollapsibleContent className="group-data-[state=open]:pt-2">
{isLoadingSharedSqlSnippets ? (
<SQLEditorLoadingSnippets />
) : numProjectSnippets === 0 ? (
<InnerSideBarEmptyPanel
className={cn('mx-2 px-3', isSearching ? '[&>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'
}
/>
<InnerSideMenuCollapsibleContent className="group-data-[state=open]:pt-2">
{numProjectSnippets === 0 ? (
<InnerSideBarEmptyPanel
className="mx-2"
title="No shared queries"
description="Share queries with your team by right-clicking on the query."
/>
) : (
<TreeView
data={projectSnippetsTreeState}
aria-label="project-level-snippets"
nodeRenderer={({ element, ...props }) => (
<SQLEditorTreeViewItem
{...props}
element={element}
onSelectDelete={() => {
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)
}}
/>
)}
) : (
<TreeView
data={projectSnippetsTreeState}
aria-label="project-level-snippets"
nodeRenderer={({ element, ...props }) => (
<SQLEditorTreeViewItem
{...props}
element={element}
onSelectDelete={() => {
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}
/>
)}
</InnerSideMenuCollapsibleContent>
</InnerSideMenuCollapsible>
<InnerSideMenuSeparator />
</>
)}
/>
)}
</InnerSideMenuCollapsibleContent>
</InnerSideMenuCollapsible>
<InnerSideMenuSeparator />
{((numFavoriteSnippets === 0 && searchText.length === 0) || numFavoriteSnippets > 0) && (
<>
<InnerSideMenuCollapsible
className="px-0"
open={showFavouriteSnippets}
onOpenChange={setShowFavouriteSnippets}
>
<InnerSideMenuCollapsibleTrigger
title={`Favorites ${numFavoriteSnippets > 0 ? ` (${numFavoriteSnippets})` : ''}`}
<InnerSideMenuCollapsible
className="px-0"
open={showFavoriteSnippets}
onOpenChange={setShowFavoriteSnippets}
>
<InnerSideMenuCollapsibleTrigger
title={`Favorites ${numFavoriteSnippets > 0 ? ` (${numFavoriteSnippets})` : ''}`}
/>
<InnerSideMenuCollapsibleContent className="group-data-[state=open]:pt-2">
{isLoadingFavoriteSqlSnippets ? (
<SQLEditorLoadingSnippets />
) : numFavoriteSnippets === 0 ? (
<InnerSideBarEmptyPanel
title={isSearching ? 'No results found based on your search' : 'No favorite queries'}
className={cn('mx-2 px-3', isSearching ? '[&>div>p]:text-foreground-lighter' : '')}
description={
isSearching ? null : (
<>
Save a query to favorites for easy accessibility by clicking the{' '}
<Heart size={12} className="inline-block relative align-center -top-[1px]" />{' '}
icon.
</>
)
}
/>
<InnerSideMenuCollapsibleContent className="group-data-[state=open]:pt-2">
{numFavoriteSnippets === 0 ? (
<InnerSideBarEmptyPanel
title="No favorite queries"
className="mx-2"
description={
<>
Save a query to favorites for easy accessbility by clicking the{' '}
<Heart size={12} className="inline-block relative align-center -top-[1px]" />{' '}
icon.
</>
}
/>
) : (
<TreeView
data={favoritesTreeState}
aria-label="favorite-snippets"
nodeRenderer={({ element, ...props }) => (
<SQLEditorTreeViewItem
{...props}
element={element}
onSelectDelete={() => {
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)
}}
/>
)}
) : (
<TreeView
data={favoritesTreeState}
aria-label="favorite-snippets"
nodeRenderer={({ element, ...props }) => (
<SQLEditorTreeViewItem
{...props}
element={element}
onSelectDelete={() => {
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}
/>
)}
</InnerSideMenuCollapsibleContent>
</InnerSideMenuCollapsible>
<InnerSideMenuSeparator />
</>
)}
/>
)}
</InnerSideMenuCollapsibleContent>
</InnerSideMenuCollapsible>
<InnerSideMenuSeparator />
<InnerSideMenuCollapsible
open={showPrivateSnippets}
@@ -455,29 +683,8 @@ export const SQLEditorNav = ({ searchText: _searchText }: SQLEditorNavProps) =>
${numPrivateSnippets > 0 ? ` (${numPrivateSnippets})` : ''}`}
/>
<InnerSideMenuCollapsibleContent className="group-data-[state=open]:pt-2">
{!snapV2.loaded[projectRef as string] ? (
<>
<div className="flex flex-row h-6 px-3 items-center gap-3">
<Skeleton className="h-4 w-5" />
<Skeleton className="w-40 h-4" />
</div>
<div className="flex flex-row h-6 px-3 items-center gap-3">
<Skeleton className="h-4 w-5" />
<Skeleton className="w-32 h-4" />
</div>
<div className="flex flex-row h-6 px-3 items-center gap-3 opacity-75">
<Skeleton className="h-4 w-5" />
<Skeleton className="w-20 h-4" />
</div>
<div className="flex flex-row h-6 px-3 items-center gap-3 opacity-50">
<Skeleton className="h-4 w-5" />
<Skeleton className="w-40 h-4" />
</div>
<div className="flex flex-row h-6 px-3 items-center gap-3 opacity-25">
<Skeleton className="h-4 w-5" />
<Skeleton className="w-20 h-4" />
</div>
</>
{isLoading ? (
<SQLEditorLoadingSnippets />
) : folders.length === 0 && numPrivateSnippets === 0 ? (
<InnerSideBarEmptyPanel
className="mx-3 px-4"
@@ -498,6 +705,7 @@ export const SQLEditorNav = ({ searchText: _searchText }: SQLEditorNavProps) =>
{...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) =>
<ConfirmationModal
size="medium"
loading={isUpserting}
title={`Confirm to share query: ${selectedSnippetToShare?.name}`}
confirmLabel="Share query"
confirmLabelLoading="Sharing query"
visible={selectedSnippetToShare !== undefined}
onCancel={() => setSelectedSnippetToShare(undefined)}
onConfirm={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',
@@ -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<string>()
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
}
@@ -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()}
>
<Link href={`/project/${ref}/sql/${element.id}`} target="_blank" rel="noreferrer">
<Link
href={`/project/${projectRef}/sql/${element.id}`}
target="_blank"
rel="noreferrer"
>
<ExternalLink size={14} />
Open in new tab
</Link>
@@ -288,6 +350,27 @@ export const SQLEditorTreeViewItem = ({
)}
</ContextMenuContent_Shadcn_>
</ContextMenu_Shadcn_>
{hasNextPage && typeof element.id === 'string' && isLastItem && (
<div
className="px-4 py-1"
style={{
paddingLeft:
!element.isBranch && element.level > 1 ? 48 * (element.level - 1) : undefined,
}}
>
<Button
type="outline"
size="tiny"
block
loading={isInFolder ? isFetchingNextPageInFolder : _isFetchingNextPage}
disabled={isInFolder ? isFetchingNextPageInFolder : _isFetchingNextPage}
onClick={fetchNextPage}
>
Load More
</Button>
</div>
)}
</>
)
}
@@ -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() {
</CommandHeader>
{isLoading && <LoadingState />}
{isError && <ErrorState />}
{isSuccess && (!snippets || snippets.snippets.length === 0) && (
{isSuccess && (!snippets || snippets.length === 0) && (
<EmptyState projectRef={ref} canCreateNew={canCreateSQLSnippet} />
)}
{isSuccess && !!snippets && snippets.snippets.length > 0 && (
<SnippetSelector
projectRef={ref}
canCreateNew={canCreateSQLSnippet}
snippets={snippets.snippets}
/>
{isSuccess && !!snippets && snippets.length > 0 && (
<SnippetSelector projectRef={ref} canCreateNew={canCreateSQLSnippet} snippets={snippets} />
)}
</CommandWrapper>
)
@@ -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()
}
@@ -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<ReturnType<typeof getContentCount>>
export type ContentIdError = ResponseError
export const useContentCountQuery = <TData = ContentIdData>(
{ projectRef, type }: getContentCountVariables,
{ projectRef, type, visibility, favorite, name }: getContentCountVariables,
{ enabled = true, ...options }: UseQueryOptions<ContentIdData, ContentIdError, TData> = {}
) =>
useQuery<ContentIdData, ContentIdError, TData>(
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,
@@ -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) {
+12 -2
View File
@@ -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<ReturnType<typeof getContentById>>
@@ -7,7 +7,10 @@ import type { ResponseError } from 'types'
import type { Content } from './content-query'
import { contentKeys } from './keys'
export type InsertContentPayload = Omit<components['schemas']['CreateContentBody'], 'content'> & {
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<ReturnType<typeof insertContent>>
@@ -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) {
+23 -35
View File
@@ -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<ContentBase, 'content' | 'type'> &
(
| {
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<ReturnType<typeof getContent>>
export type ContentError = unknown
export const useContentQuery = <TData = ContentData>(
projectRef: string | undefined,
{ projectRef, type }: GetContentVariables,
{ enabled = true, ...options }: UseQueryOptions<ContentData, ContentError, TData> = {}
) =>
useQuery<ContentData, ContentError, TData>(
contentKeys.list(projectRef),
({ signal }) => getContent(projectRef, signal),
contentKeys.list(projectRef, type),
({ signal }) => getContent({ projectRef, type }, signal),
{ enabled: enabled && typeof projectRef !== 'undefined', ...options }
)
@@ -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<ReturnType<typeof updateContent>>
@@ -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) {
@@ -7,7 +7,10 @@ import type { ResponseError } from 'types'
import type { Content } from './content-query'
import { contentKeys } from './keys'
export type UpsertContentPayload = Omit<components['schemas']['UpsertContentBody'], 'content'> & {
export type UpsertContentPayload = Omit<
components['schemas']['UpsertContentBodyDto'],
'content'
> & {
content: Partial<Content['content']>
}
@@ -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)
},
@@ -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)
}
+29 -5
View File
@@ -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),
}
@@ -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<ReturnType<typeof getSQLSnippetFolderContents>>
export type SQLSnippetFolderContentsError = ResponseError
export const useSQLSnippetFolderContentsQuery = <TData = SQLSnippetFolderContentsData>(
{ projectRef, folderId, name, sort }: Omit<SQLSnippetFolderContentsVariables, 'cursor'>,
{
enabled = true,
...options
}: UseInfiniteQueryOptions<
SQLSnippetFolderContentsData,
SQLSnippetFolderContentsError,
TData
> = {}
) =>
useInfiniteQuery<SQLSnippetFolderContentsData, SQLSnippetFolderContentsError, TData>(
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,
}
)
+46 -24
View File
@@ -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<ReturnType<typeof getSQLSnippetFolde
export type SQLSnippetFoldersError = ResponseError
export const useSQLSnippetFoldersQuery = <TData = SQLSnippetFoldersData>(
{ projectRef, folderId }: { projectRef?: string; folderId?: string },
{ projectRef, name, sort }: Omit<SQLSnippetFolderVariables, 'cursor'>,
{
enabled = true,
...options
}: UseQueryOptions<SQLSnippetFoldersData, SQLSnippetFoldersError, TData> = {}
}: UseInfiniteQueryOptions<SQLSnippetFoldersData, SQLSnippetFoldersError, TData> = {}
) =>
useQuery<SQLSnippetFoldersData, SQLSnippetFoldersError, TData>(
contentKeys.folders(projectRef, folderId),
({ signal }) => getSQLSnippetFolders({ projectRef, folderId }, signal),
{ enabled: enabled && typeof projectRef !== 'undefined', ...options }
useInfiniteQuery<SQLSnippetFoldersData, SQLSnippetFoldersError, TData>(
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,
}
)
+70 -18
View File
@@ -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<Content, { type: 'sql' | 'log_sql' | 'report' }>
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<Content, { type: 'sql' }>
interface GetSqlSnippetsVariables {
projectRef?: string
cursor?: string
visibility?: SqlSnippet['visibility']
favorite?: boolean
name?: string
sort?: 'name' | 'inserted_at'
}
function filterSqlContent(content: Content): content is Extract<Content, { type: 'sql' }> {
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<ContentData, ContentError, SqlSnippets> = {}
) =>
useContentQuery<SqlSnippets>(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<ReturnType<typeof getSqlSnippets>>
export type SqlSnippetsError = unknown
export const useSqlSnippetsQuery = <TData = SqlSnippetsData>(
{ projectRef, sort, name, visibility, favorite }: Omit<GetSqlSnippetsVariables, 'cursor'>,
{
enabled = true,
...options
}: UseInfiniteQueryOptions<SqlSnippetsData, SqlSnippetsError, TData> = {}
) =>
useInfiniteQuery<SqlSnippetsData, SqlSnippetsError, TData>(
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,
}
)
@@ -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 } : {}),
-1
View File
@@ -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",
@@ -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`)
},
})
@@ -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 <Loading active={true}>{null}</Loading>
@@ -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 },
+24 -13
View File
@@ -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 (
+84 -193
View File
@@ -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<string>([]),
// 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<Snippet>
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<typeof useSnaps
export const useSnippetFolders = (projectRef: string) => {
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)
-1
View File
@@ -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",
+106 -301
View File
@@ -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<string, never>
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<string, never>
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<string, never>
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<string, never>
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<string, never>
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<string, never>
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<string, never>
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<string, never>
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<string, never>
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
}
}
+22 -5
View File
@@ -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<HTMLDivElement, React.ComponentPropsWitho
const InnerSideBarFilterSearchInput = forwardRef<
HTMLInputElement,
React.ComponentPropsWithoutRef<typeof Input_Shadcn_> & { 'aria-labelledby': string; name: string }
>(({ children, ...props }, ref) => {
React.ComponentPropsWithoutRef<typeof Input_Shadcn_> & {
'aria-labelledby': string
name: string
isLoading?: boolean
}
>(({ children, isLoading = false, ...props }, ref) => {
return (
<label htmlFor={props.name} className="relative w-full">
<span className="sr-only">{props['aria-labelledby']}</span>
@@ -159,7 +163,20 @@ const InnerSideBarFilterSearchInput = forwardRef<
{...props}
/>
{children}
<Search className="absolute left-2 top-2 text-foreground-muted" size={14} strokeWidth={1.5} />
{isLoading ? (
<Loader2
className="animate-spin absolute left-2 text-foreground-muted"
style={{ top: 7 }}
size={14}
strokeWidth={1.5}
/>
) : (
<Search
className="absolute left-2 top-2 text-foreground-muted"
size={14}
strokeWidth={1.5}
/>
)}
</label>
)
})
@@ -251,10 +268,10 @@ const InnerSideBarEmptyPanel = forwardRef<
export {
InnerSideBarEmptyPanel,
InnerSideBarFilters,
InnerSideBarFilterSearchInput,
InnerSideBarFilterSortDropdown,
InnerSideBarFilterSortDropdownItem,
InnerSideBarFilters,
InnerSideBarShimmeringLoaders,
InnerSideBarTitle,
InnerSideMenuCollapsible,