mirror of
https://github.com/supabase/supabase.git
synced 2026-10-18 23:59:08 +03:00
* chore: cleanup packages - Avoid circular imports - Export API-types as types - pg-format without depending on Node internal Buffer (not browser-compatible) - Avoid importing from barrel files in ui dir * chore: avoid barrel file imports in studio (#27771) * chore: avoid barrel file imports - Removes some unused imports - Avoids barrel file import for faster builds + less memory * add eslint rule * type fixes * delete layouts barrel * delete components/grid barrel file * delete components/grid/utils barrel file * delete components/grid/components/common barrel file * delete components/grid/components/editor barrel file * delete components/grid/components/formatter barrel file * delete components/grid/components/grid barrel file * delete components/grid/components/header/filter barrel file * remote components/grid/store barrel file * remove components/interfaces/Auth/Policies barrel file * delete components/interfaces/Settings/Logs barrel file * delete components/ui/CodeEditor barrel file * delete components/ui/Forms barrel file * delete components/ui/Shimmers barrel file * delete data/analytics barrel file * delete hooks barrel file * cleanup lib/common/fetch barrel file * final * barral files cleanup * global react-data-grid styles * remove console.log --------- Co-authored-by: Kevin Grüneberg <k.grueneberg1994@gmail.com> * fix build --------- Co-authored-by: Kevin Grüneberg <k.grueneberg1994@gmail.com>
382 lines
14 KiB
TypeScript
382 lines
14 KiB
TypeScript
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
|
import { partition } from 'lodash'
|
|
import { Plus } from 'lucide-react'
|
|
import { useRouter } from 'next/router'
|
|
import { useMemo, useState } from 'react'
|
|
import toast from 'react-hot-toast'
|
|
|
|
import { useParams } from 'common'
|
|
import { untitledSnippetTitle } from 'components/interfaces/SQLEditor/SQLEditor.constants'
|
|
import { createSqlSnippetSkeleton } from 'components/interfaces/SQLEditor/SQLEditor.utils'
|
|
import AlertError from 'components/ui/AlertError'
|
|
import { useContentDeleteMutation } from 'data/content/content-delete-mutation'
|
|
import { SqlSnippet, useSqlSnippetsQuery } from 'data/content/sql-snippets-query'
|
|
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
|
import { useSelectedProject } from 'hooks/misc/useSelectedProject'
|
|
import { uuidv4 } from 'lib/helpers'
|
|
import { useProfile } from 'lib/profile'
|
|
import { useSnippets, useSqlEditorStateSnapshot } from 'state/sql-editor'
|
|
import { ResponseError } from 'types'
|
|
import { Button, TooltipContent_Shadcn_, TooltipTrigger_Shadcn_, Tooltip_Shadcn_ } from 'ui'
|
|
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
|
import {
|
|
InnerSideBarEmptyPanel,
|
|
InnerSideBarFilterSearchInput,
|
|
InnerSideBarFilters,
|
|
InnerSideBarShimmeringLoaders,
|
|
InnerSideMenuCollapsible,
|
|
InnerSideMenuCollapsibleContent,
|
|
InnerSideMenuCollapsibleTrigger,
|
|
InnerSideMenuItem,
|
|
InnerSideMenuSeparator,
|
|
} from 'ui-patterns/InnerSideMenu'
|
|
import QueryItem from './QueryItem'
|
|
import { selectItemsInRange } from './SQLEditorLayout.utils'
|
|
|
|
export const SQLEditorMenu = ({ onViewOngoingQueries }: { onViewOngoingQueries: () => void }) => {
|
|
const { ref, id: activeId } = useParams()
|
|
const router = useRouter()
|
|
const { profile } = useProfile()
|
|
const project = useSelectedProject()
|
|
|
|
const [searchText, setSearchText] = useState('')
|
|
const [showDeleteModal, setShowDeleteModal] = useState(false)
|
|
const [selectedQueries, setSelectedQueries] = useState<string[]>([])
|
|
|
|
const snap = useSqlEditorStateSnapshot()
|
|
const { isLoading, isSuccess, isError, error } = useSqlSnippetsQuery(ref, {
|
|
refetchOnWindowFocus: false,
|
|
staleTime: 300, // 5 minutes
|
|
onSuccess(data) {
|
|
if (ref) snap.setRemoteSnippets(data.snippets, ref)
|
|
},
|
|
})
|
|
|
|
const { mutate: deleteContent, isLoading: isDeleting } = useContentDeleteMutation({
|
|
onSuccess: (data) => postDeleteCleanup(data),
|
|
onError: (error, data) => {
|
|
if (error.code === 404 && error.message.includes('Content not found')) {
|
|
postDeleteCleanup(data.ids)
|
|
} else {
|
|
toast.error(`Failed to delete queries: ${error.message}`)
|
|
}
|
|
},
|
|
})
|
|
|
|
const postDeleteCleanup = (ids: string[]) => {
|
|
setShowDeleteModal(false)
|
|
setSelectedQueries([])
|
|
if (ids.length > 0) ids.forEach((id) => snap.removeSnippet(id))
|
|
const existingSnippetIds = (snap.orders[ref!] ?? []).filter((x) => !ids.includes(x))
|
|
if (existingSnippetIds.length === 0) {
|
|
router.push(`/project/${ref}/sql/new`)
|
|
} else if (ids.includes(activeId as string)) {
|
|
router.push(`/project/${ref}/sql/${existingSnippetIds[0]}`)
|
|
}
|
|
}
|
|
|
|
const snippets = useSnippets(ref)
|
|
|
|
const projectSnippets = useMemo(() => {
|
|
return snippets.filter((snippet) => snippet.visibility === 'project')
|
|
}, [snippets])
|
|
|
|
const filteredProjectSnippets = useMemo(() => {
|
|
if (searchText.length > 0) {
|
|
return projectSnippets.filter((tab) =>
|
|
tab.name.toLowerCase().includes(searchText.toLowerCase())
|
|
)
|
|
}
|
|
return projectSnippets
|
|
}, [projectSnippets, searchText])
|
|
|
|
const personalSnippets = useMemo(() => {
|
|
const ss = snippets.filter(
|
|
(snippet) => snippet.visibility === 'user' && !snippet.content.favorite
|
|
)
|
|
|
|
if (searchText.length > 0) {
|
|
return ss.filter((tab) => tab.name.toLowerCase().includes(searchText.toLowerCase()))
|
|
}
|
|
return ss
|
|
}, [searchText, snippets])
|
|
|
|
const favoriteSnippets = useMemo(() => {
|
|
return snippets.filter((snippet) => snippet.content.favorite)
|
|
}, [snippets])
|
|
|
|
const filteredFavoriteSnippets = useMemo(() => {
|
|
if (searchText.length > 0) {
|
|
return favoriteSnippets.filter((tab) =>
|
|
tab.name.toLowerCase().includes(searchText.toLowerCase())
|
|
)
|
|
}
|
|
return favoriteSnippets
|
|
}, [favoriteSnippets, searchText])
|
|
|
|
const canCreateSQLSnippet = useCheckPermissions(PermissionAction.CREATE, 'user_content', {
|
|
resource: { type: 'sql', owner_id: profile?.id },
|
|
subject: { id: profile?.id },
|
|
})
|
|
|
|
const onSelectQuery = (id?: string, isShiftHeld: boolean = false) => {
|
|
if (id === undefined) return
|
|
|
|
if (isShiftHeld) {
|
|
const [selectedFavSnippets, selectedOtherSnippets] = partition(
|
|
selectedQueries,
|
|
(id) => snippets.find((x) => x.id === id)?.content.favorite
|
|
)
|
|
const selectedSnippet = snippets.find((x) => x.id === id)
|
|
const isFavourite = selectedSnippet?.content.favorite
|
|
|
|
if (isFavourite) {
|
|
if (selectedFavSnippets.length > 0) {
|
|
const updatedSelectedFavSnippets = selectItemsInRange(
|
|
id,
|
|
filteredFavoriteSnippets,
|
|
selectedFavSnippets
|
|
)
|
|
setSelectedQueries(updatedSelectedFavSnippets.concat(selectedOtherSnippets))
|
|
} else {
|
|
selectQuery(id)
|
|
}
|
|
} else {
|
|
if (selectedOtherSnippets.length > 0) {
|
|
const updatedSelectedOtherSnippets = selectItemsInRange(
|
|
id,
|
|
personalSnippets,
|
|
selectedOtherSnippets
|
|
)
|
|
setSelectedQueries(updatedSelectedOtherSnippets.concat(selectedFavSnippets))
|
|
} else {
|
|
selectQuery(id)
|
|
}
|
|
}
|
|
} else {
|
|
selectQuery(id)
|
|
}
|
|
}
|
|
|
|
const selectQuery = (id: string) => {
|
|
if (selectedQueries.includes(id)) {
|
|
setSelectedQueries(selectedQueries.filter((x) => x !== id))
|
|
} else {
|
|
setSelectedQueries(selectedQueries.concat([id]))
|
|
}
|
|
}
|
|
|
|
const handleNewQuery = async () => {
|
|
if (!ref) return console.error('Project is required')
|
|
if (!profile) return console.error('Profile is required')
|
|
if (!canCreateSQLSnippet) {
|
|
return toast('Your queries will not be saved as you do not have sufficient permissions')
|
|
}
|
|
|
|
try {
|
|
const snippet = createSqlSnippetSkeleton({
|
|
id: uuidv4(),
|
|
name: untitledSnippetTitle,
|
|
owner_id: profile?.id,
|
|
project_id: project?.id,
|
|
})
|
|
|
|
snap.addSnippet(snippet as SqlSnippet, ref)
|
|
|
|
router.push(`/project/${ref}/sql/${snippet.id}`)
|
|
setSearchText('')
|
|
} catch (error: any) {
|
|
toast.error(`Failed to create new query: ${error.message}`)
|
|
}
|
|
}
|
|
|
|
const onConfirmDelete = async () => {
|
|
if (!ref) return console.error('Project ref is required')
|
|
deleteContent({ projectRef: ref, ids: selectedQueries })
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div className="h-full flex flex-col justify-between">
|
|
<div className="mt-4 mb-2 flex flex-col gap-4">
|
|
<div className="mx-4">
|
|
<Button
|
|
block
|
|
type="default"
|
|
className="justify-start"
|
|
onClick={() => handleNewQuery()}
|
|
icon={<Plus className="text-foreground-muted" strokeWidth={1} size={14} />}
|
|
>
|
|
New query
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="px-2">
|
|
<InnerSideMenuItem
|
|
title="Templates"
|
|
isActive={router.asPath === `/project/${ref}/sql/templates`}
|
|
href={`/project/${ref}/sql/templates`}
|
|
>
|
|
Templates
|
|
</InnerSideMenuItem>
|
|
<InnerSideMenuItem
|
|
title="Quickstarts"
|
|
isActive={router.asPath === `/project/${ref}/sql/quickstarts`}
|
|
href={`/project/${ref}/sql/quickstarts`}
|
|
>
|
|
Quickstarts
|
|
</InnerSideMenuItem>
|
|
</div>
|
|
|
|
{isLoading && <InnerSideBarShimmeringLoaders />}
|
|
|
|
{isError && (
|
|
<div className="px-4">
|
|
<AlertError error={error as ResponseError} subject="Failed to load SQL snippets" />
|
|
</div>
|
|
)}
|
|
|
|
{isSuccess && (
|
|
<>
|
|
{snippets.length > 0 && (
|
|
<InnerSideBarFilters className="mx-2">
|
|
<InnerSideBarFilterSearchInput
|
|
name="search-queries"
|
|
placeholder="Search queries..."
|
|
onChange={(e) => setSearchText(e.target.value)}
|
|
value={searchText}
|
|
aria-labelledby="Search queries"
|
|
/>
|
|
</InnerSideBarFilters>
|
|
)}
|
|
|
|
{searchText.length > 0 &&
|
|
filteredProjectSnippets.length === 0 &&
|
|
filteredFavoriteSnippets.length === 0 &&
|
|
filteredProjectSnippets.length === 0 && (
|
|
<InnerSideBarEmptyPanel
|
|
className="mx-4"
|
|
title="No project queries found"
|
|
description="Click the New query button to create a new query"
|
|
actions={
|
|
<Button type="default" onClick={() => handleNewQuery()}>
|
|
New query
|
|
</Button>
|
|
}
|
|
/>
|
|
)}
|
|
|
|
{filteredProjectSnippets.length > 0 && (
|
|
<InnerSideMenuCollapsible className="editor-product-menu" defaultOpen>
|
|
<InnerSideMenuCollapsibleTrigger title="Project queries" />
|
|
<InnerSideMenuCollapsibleContent>
|
|
<>
|
|
{filteredProjectSnippets.map((tabInfo) => (
|
|
<QueryItem
|
|
key={tabInfo.id}
|
|
tabInfo={tabInfo}
|
|
onDeleteQuery={postDeleteCleanup}
|
|
hasQueriesSelected={selectedQueries.length > 0}
|
|
/>
|
|
))}
|
|
</>
|
|
</InnerSideMenuCollapsibleContent>
|
|
</InnerSideMenuCollapsible>
|
|
)}
|
|
|
|
{selectedQueries.length > 0 && (
|
|
<>
|
|
<InnerSideMenuSeparator />
|
|
<div className="px-4 flex items-center gap-x-2 py-2">
|
|
<Button block type="danger" onClick={() => setShowDeleteModal(true)}>
|
|
Delete {selectedQueries.length.toLocaleString()} quer
|
|
{selectedQueries.length > 1 ? 'ies' : 'y'}
|
|
</Button>
|
|
<Tooltip_Shadcn_ delayDuration={100}>
|
|
<TooltipTrigger_Shadcn_ asChild>
|
|
<Button type="default" onClick={() => setSelectedQueries([])}>
|
|
Cancel
|
|
</Button>
|
|
</TooltipTrigger_Shadcn_>
|
|
<TooltipContent_Shadcn_ side="bottom">Clear selection</TooltipContent_Shadcn_>
|
|
</Tooltip_Shadcn_>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{filteredFavoriteSnippets.length > 0 && (
|
|
<InnerSideMenuCollapsible className="editor-product-menu" defaultOpen>
|
|
<InnerSideMenuCollapsibleTrigger title="Favorites" />
|
|
<InnerSideMenuCollapsibleContent>
|
|
<>
|
|
{filteredFavoriteSnippets.map((tabInfo) => (
|
|
<QueryItem
|
|
key={tabInfo.id}
|
|
tabInfo={tabInfo}
|
|
isSelected={selectedQueries.includes(tabInfo.id as string)}
|
|
hasQueriesSelected={selectedQueries.length > 0}
|
|
onSelectQuery={(isShiftHeld) => onSelectQuery(tabInfo.id, isShiftHeld)}
|
|
onDeleteQuery={postDeleteCleanup}
|
|
/>
|
|
))}
|
|
</>
|
|
</InnerSideMenuCollapsibleContent>
|
|
</InnerSideMenuCollapsible>
|
|
)}
|
|
|
|
{personalSnippets.length > 0 && (
|
|
<InnerSideMenuCollapsible className="editor-product-menu" defaultOpen>
|
|
<InnerSideMenuCollapsibleTrigger title="Your queries" />
|
|
<InnerSideMenuCollapsibleContent className="editor-product-menu">
|
|
<>
|
|
<div className="space-y-0.5">
|
|
{personalSnippets.map((tabInfo) => (
|
|
<QueryItem
|
|
key={tabInfo.id}
|
|
tabInfo={tabInfo}
|
|
isSelected={selectedQueries.includes(tabInfo.id as string)}
|
|
hasQueriesSelected={selectedQueries.length > 0}
|
|
onSelectQuery={(isShiftHeld) => onSelectQuery(tabInfo.id, isShiftHeld)}
|
|
onDeleteQuery={postDeleteCleanup}
|
|
/>
|
|
))}
|
|
</div>
|
|
</>
|
|
</InnerSideMenuCollapsibleContent>
|
|
</InnerSideMenuCollapsible>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<div className="p-4 border-t sticky bottom-0 bg-studio">
|
|
<Button block type="default" onClick={onViewOngoingQueries}>
|
|
View running queries
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<ConfirmationModal
|
|
title="Confirm to delete query"
|
|
confirmLabel={`Delete ${selectedQueries.length.toLocaleString()} quer${selectedQueries.length > 1 ? 'ies' : 'y'}`}
|
|
confirmLabelLoading="Deleting query"
|
|
size="medium"
|
|
loading={isDeleting}
|
|
visible={showDeleteModal}
|
|
onConfirm={onConfirmDelete}
|
|
onCancel={() => setShowDeleteModal(false)}
|
|
variant={'destructive'}
|
|
alert={{
|
|
title: 'This action cannot be undone',
|
|
description: 'The selected SQL snippets cannot be recovered once deleted',
|
|
}}
|
|
>
|
|
<p className="text-sm text-foreground-light">
|
|
Are you sure you want to delete the selected {selectedQueries.length} quer
|
|
{selectedQueries.length > 1 ? 'ies' : 'y'}?
|
|
</p>
|
|
</ConfirmationModal>
|
|
</>
|
|
)
|
|
}
|