Basic bulk delete query support

This commit is contained in:
Joshen Lim committed 2024-02-01 15:31:54 +07:00
1 parent 9a30c1029d
commit b8b0066a39
5 files changed
+300 -157

No files matched your search

@@ -19,9 +19,7 @@ const ProductMenuBar = ({ title, children }: PropsWithChildren<ProductMenuBarPro
>
<h4 className="text-lg">{title}</h4>
</div>
<div className="flex-grow overflow-y-auto" style={{ maxHeight: 'calc(100vh - 96px)' }}>
{children}
</div>
<div className="flex-grow overflow-y-auto">{children}</div>
</div>
)
}
@@ -1,5 +1,6 @@
import clsx from 'clsx'
import { useParams } from 'common'
import { noop } from 'lodash'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useEffect, useRef, useState } from 'react'
@@ -8,6 +9,7 @@ import {
AlertDescription_Shadcn_,
AlertTitle_Shadcn_,
Alert_Shadcn_,
Checkbox_Shadcn_,
IconAlertCircle,
IconAlertTriangle,
IconEye,
@@ -30,10 +32,19 @@ import { QueryItemActions } from './QueryItemActions'
export interface QueryItemProps {
tabInfo: SqlSnippet
isSelected?: boolean
hasQueriesSelected?: boolean
onSelectQuery?: () => void
onDeleteQuery?: (ids: string[]) => void
}
const QueryItem = ({ tabInfo }: QueryItemProps) => {
const router = useRouter()
const QueryItem = ({
tabInfo,
isSelected = false,
hasQueriesSelected = false,
onSelectQuery = noop,
onDeleteQuery = noop,
}: QueryItemProps) => {
const [open, setOpen] = useState(false)
const [renameModalOpen, setRenameModalOpen] = useState(false)
const [shareModalOpen, setShareModalOpen] = useState(false)
@@ -42,7 +53,7 @@ const QueryItem = ({ tabInfo }: QueryItemProps) => {
const { ref, id: activeId } = useParams()
const snap = useSqlEditorStateSnapshot()
const { id, name, description, content } = tabInfo || {}
const { id, name, description, content, visibility } = tabInfo || {}
const isActive = id === activeId
const activeItemRef = useRef<HTMLElement | null>(null)
@@ -55,25 +66,10 @@ const QueryItem = ({ tabInfo }: QueryItemProps) => {
isDownloadSnippetModalOpen
const { mutate: deleteContent, isLoading: isDeleting } = useContentDeleteMutation({
onSuccess(data) {
if (data.length > 0) snap.removeSnippet(data[0])
const existingSnippetIds = (snap.orders[ref!] ?? []).filter((x) => x !== id)
if (existingSnippetIds.length === 0) {
router.push(`/project/${ref}/sql/new`)
} else {
router.push(`/project/${ref}/sql/${existingSnippetIds[0]}`)
}
},
onError(error, data) {
onSuccess: (data) => onDeleteQuery(data),
onError: (error, data) => {
if (error.code === 404 && error.message.includes('Content not found')) {
if (data.ids) snap.removeSnippet(data.ids[0])
const existingSnippetIds = (snap.orders[ref!] ?? []).filter((x) => x !== id)
if (existingSnippetIds.length === 0) {
router.push(`/project/${ref}/sql/new`)
} else {
router.push(`/project/${ref}/sql/${existingSnippetIds[0]}`)
}
onDeleteQuery(data.ids)
} else {
toast.error(`Failed to delete query: ${error.message}`)
}
@@ -114,26 +110,42 @@ const QueryItem = ({ tabInfo }: QueryItemProps) => {
<div
key={id}
className={clsx(
'flex items-center justify-between rounded-md group',
'h-7 pl-3 pr-2',
'flex items-center justify-between rounded-md group relative',
isActive ? 'bg-surface-300' : 'hover:bg-surface-200'
)}
ref={isActive ? (activeItemRef as React.RefObject<HTMLDivElement>) : null}
>
<Link href={`/project/${ref}/sql/${id}`} className="py-1 px-3 w-full overflow-hidden">
<p
title={description || name}
{visibility === 'user' && (
<Checkbox_Shadcn_
className={clsx(
'transition absolute left-2.5 border-foreground-lighter',
hasQueriesSelected ? '' : 'opacity-0 group-hover:opacity-100'
)}
checked={isSelected}
onCheckedChange={() => onSelectQuery()}
/>
)}
<div className="flex items-center justify-between w-full gap-x-2">
<Link
title={description || name}
href={`/project/${ref}/sql/${id}`}
className={clsx(
'w-full overflow-hidden truncate',
isActive
? 'text-foreground'
: 'text-foreground-light group-hover:text-foreground/80',
'text-sm transition overflow-hidden text-ellipsis'
'text-sm transition-all overflow-hidden text-ellipsis',
hasQueriesSelected && visibility === 'user'
? 'ml-5'
: visibility === 'user'
? 'group-hover:ml-5'
: ''
)}
>
{name}
</p>
</Link>
<div className="pr-1">
{
</Link>
{!hasQueriesSelected && (
<QueryItemActions
tabInfo={tabInfo}
activeId={activeId}
@@ -144,7 +156,7 @@ const QueryItem = ({ tabInfo }: QueryItemProps) => {
onSelectShareQuery={() => setShareModalOpen(true)}
onSelectDownloadQuery={() => setIsDownloadSnippetModalOpen(true)}
/>
}
)}
</div>
</div>
</TooltipTrigger_Shadcn_>
@@ -200,7 +212,7 @@ const QueryItem = ({ tabInfo }: QueryItemProps) => {
<div className="my-6">
<div className="text-sm text-foreground-light grid gap-4">
<div className="grid gap-1">
{tabInfo.visibility === 'project' && (
{visibility === 'project' && (
<Alert_Shadcn_ variant="destructive">
<IconAlertCircle strokeWidth={2} />
<AlertTitle_Shadcn_>This SQL snippet will be lost forever</AlertTitle_Shadcn_>
@@ -54,7 +54,6 @@ export const QueryItemActions = ({
const project = useSelectedProject()
const { id, name, visibility, content } = tabInfo || {}
const isActive = id === activeId
const canCreateSQLSnippet = useCheckPermissions(PermissionAction.CREATE, 'user_content', {
resource: { type: 'sql', owner_id: profile?.id },
@@ -111,20 +110,13 @@ export const QueryItemActions = ({
{IS_PLATFORM ? (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<span
tabIndex={0}
onClick={() => setOpen(true)}
className={clsx(
'rounded p-1 cursor-pointer',
isActive
? 'text-foreground-light hover:bg-border-stronger'
: 'text-background hover:bg-overlay-hover group-hover:text-foreground-light'
)}
>
<IconChevronDown size="tiny" strokeWidth={2} />
</span>
<Button
type="text"
className="px-1 opacity-50 hover:opacity-100"
icon={<IconChevronDown size="tiny" strokeWidth={2} />}
/>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" className=" w-52 translate-x-[4px]">
<DropdownMenuContent side="bottom" align="end" className=" w-52 translate-x-2">
{isSnippetOwner && (
<DropdownMenuItem onClick={onClickRename} className="space-x-2">
<IconEdit2 size="tiny" />
@@ -3,12 +3,31 @@ import { useParams } from 'common'
import { observer } from 'mobx-react-lite'
import { useRouter } from 'next/router'
import { useMemo, useState } from 'react'
import { Button, IconPlus, IconSearch, IconX, Input, Menu, cn } from 'ui'
import toast from 'react-hot-toast'
import {
AlertDescription_Shadcn_,
AlertTitle_Shadcn_,
Alert_Shadcn_,
Button,
IconPlus,
IconSearch,
IconX,
Input,
Menu,
Modal,
Separator,
TooltipContent_Shadcn_,
TooltipTrigger_Shadcn_,
Tooltip_Shadcn_,
} from 'ui'
import { untitledSnippetTitle } from 'components/interfaces/SQLEditor/SQLEditor.constants'
import { createSqlSnippetSkeleton } from 'components/interfaces/SQLEditor/SQLEditor.utils'
import ConfirmationModal from 'components/ui/ConfirmationModal'
import { WarningIcon } from 'components/ui/Icons'
import ProductMenuItem from 'components/ui/ProductMenu/ProductMenuItem'
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
import { useContentDeleteMutation } from 'data/content/content-delete-mutation'
import { SqlSnippet, useSqlSnippetsQuery } from 'data/content/sql-snippets-query'
import { useCheckPermissions, useSelectedProject, useStore } from 'hooks'
import { uuidv4 } from 'lib/helpers'
@@ -18,11 +37,14 @@ import QueryItem from './QueryItem'
const SideBarContent = observer(() => {
const { ui } = useStore()
const { ref } = useParams()
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 } = useSqlSnippetsQuery(ref, {
@@ -33,6 +55,29 @@ const SideBarContent = observer(() => {
},
})
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(() => {
@@ -77,6 +122,16 @@ const SideBarContent = observer(() => {
subject: { id: profile?.id },
})
const onSelectQuery = (id?: string) => {
if (id !== undefined) {
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')
@@ -107,120 +162,206 @@ const SideBarContent = observer(() => {
}
}
return (
<div className="mt-6">
<Menu type="pills">
{isLoading ? (
<div className="px-5 my-4 space-y-2">
<ShimmeringLoader />
<ShimmeringLoader className="w-3/4" />
<ShimmeringLoader className="w-1/2" />
</div>
) : isSuccess ? (
<div className="space-y-6">
<div className="px-4 flex flex-col gap-2">
<Button
type="default"
className="justify-start"
onClick={() => handleNewQuery()}
icon={<IconPlus size="tiny" />}
>
New query
</Button>
<Input
className="table-editor-search border-none"
icon={
<IconSearch className="text-foreground-lighter" size={12} strokeWidth={1.5} />
}
placeholder="Search queries"
onChange={(e) => setSearchText(e.target.value.trim())}
value={searchText}
size="tiny"
actions={
searchText && (
<Button type="text" className="px-1" onClick={() => setSearchText('')}>
<IconX size={12} strokeWidth={2} />
</Button>
)
}
/>
</div>
<div className="space-y-6 px-4">
{searchText.length === 0 && (
<div>
<ProductMenuItem
name="Templates"
isActive={router.asPath === `/project/${ref}/sql/templates`}
url={`/project/${ref}/sql/templates`}
/>
<ProductMenuItem
name="Quickstarts"
isActive={router.asPath === `/project/${ref}/sql/quickstarts`}
url={`/project/${ref}/sql/quickstarts`}
/>
</div>
)}
const onConfirmDelete = async () => {
if (!ref) return console.error('Project ref is required')
deleteContent({ projectRef: ref, ids: selectedQueries })
}
{searchText.length > 0 &&
filteredFavoriteSnippets.length === 0 &&
filteredProjectSnippets.length === 0 &&
personalSnippets.length === 0 && (
<div className="h-32 border border-dashed flex flex-col gap-y-3 items-center justify-center rounded px-3">
<div className="flex flex-col gap-y-1 items-center justify-center">
<p className="text-xs text-foreground">No queries found</p>
<p className="text-xs text-foreground-light text-center">
Your search for "{searchText}" did not return any results
</p>
</div>
<Button type="default" onClick={() => handleNewQuery()}>
New query
</Button>
return (
<>
<div className="mt-6">
<Menu type="pills">
{isLoading ? (
<div className="px-5 my-4 space-y-2">
<ShimmeringLoader />
<ShimmeringLoader className="w-3/4" />
<ShimmeringLoader className="w-1/2" />
</div>
) : isSuccess ? (
<div className="space-y-6">
<div className="px-4 flex flex-col gap-2">
<Button
type="default"
className="justify-start"
onClick={() => handleNewQuery()}
icon={<IconPlus size="tiny" />}
>
New query
</Button>
<Input
className="table-editor-search border-none"
icon={
<IconSearch className="text-foreground-lighter" size={12} strokeWidth={1.5} />
}
placeholder="Search queries"
onChange={(e) => setSearchText(e.target.value.trim())}
value={searchText}
size="tiny"
actions={
searchText && (
<Button type="text" className="px-1" onClick={() => setSearchText('')}>
<IconX size={12} strokeWidth={2} />
</Button>
)
}
/>
</div>
<div className="flex flex-col gap-y-6 px-2">
{searchText.length === 0 && (
<div>
<ProductMenuItem
name="Templates"
isActive={router.asPath === `/project/${ref}/sql/templates`}
url={`/project/${ref}/sql/templates`}
/>
<ProductMenuItem
name="Quickstarts"
isActive={router.asPath === `/project/${ref}/sql/quickstarts`}
url={`/project/${ref}/sql/quickstarts`}
/>
</div>
)}
{filteredProjectSnippets.length > 0 ? (
<div className="editor-product-menu">
<Menu.Group title="Project queries" />
{searchText.length > 0 &&
filteredFavoriteSnippets.length === 0 &&
filteredProjectSnippets.length === 0 &&
personalSnippets.length === 0 && (
<div className="h-32 border border-dashed flex flex-col gap-y-3 items-center justify-center rounded px-3">
<div className="flex flex-col gap-y-1 items-center justify-center">
<p className="text-xs text-foreground">No queries found</p>
<p className="text-xs text-foreground-light text-center">
Your search for "{searchText}" did not return any results
</p>
</div>
<Button type="default" onClick={() => handleNewQuery()}>
New query
</Button>
</div>
)}
<div className="space-y-1">
{filteredProjectSnippets.map((tabInfo) => {
const { id } = tabInfo || {}
return <QueryItem key={id} tabInfo={tabInfo} />
})}
</div>
</div>
) : null}
{filteredProjectSnippets.length > 0 ? (
<div className="editor-product-menu">
<Menu.Group title="Project queries" />
{filteredFavoriteSnippets.length > 0 ? (
<div className="editor-product-menu">
<Menu.Group title="Favorites" />
<div className="space-y-1">
{filteredFavoriteSnippets.map((tabInfo) => {
const { id } = tabInfo || {}
return <QueryItem key={id} tabInfo={tabInfo} />
})}
<div className="space-y-1">
{filteredProjectSnippets.map((tabInfo) => {
const { id } = tabInfo || {}
return (
<QueryItem
key={id}
tabInfo={tabInfo}
hasQueriesSelected={selectedQueries.length > 0}
/>
)
})}
</div>
</div>
</div>
) : null}
) : null}
{personalSnippets.length > 0 ? (
<div className="editor-product-menu">
<Menu.Group title="Your queries" />
<div className="space-y-1 pb-8">
{personalSnippets.map((tabInfo) => {
const { id } = tabInfo || {}
return <QueryItem key={id} tabInfo={tabInfo} />
})}
{selectedQueries.length > 0 && (
<>
<Separator />
<div className="px-2 flex items-center gap-x-2">
<Button block type="default" onClick={() => setShowDeleteModal(true)}>
Delete {selectedQueries.length.toLocaleString()} quer
{selectedQueries.length > 1 ? 'ies' : 'y'}
</Button>
<Tooltip_Shadcn_ delayDuration={100}>
<TooltipTrigger_Shadcn_ asChild>
<Button
type="default"
className="px-1"
icon={<IconX />}
onClick={() => setSelectedQueries([])}
/>
</TooltipTrigger_Shadcn_>
<TooltipContent_Shadcn_ side="bottom">
Clear selection
</TooltipContent_Shadcn_>
</Tooltip_Shadcn_>
</div>
</>
)}
{filteredFavoriteSnippets.length > 0 ? (
<div className="editor-product-menu">
<Menu.Group title="Favorites" />
<div className="space-y-1">
{filteredFavoriteSnippets.map((tabInfo) => {
const { id } = tabInfo || {}
return (
<QueryItem
key={id}
tabInfo={tabInfo}
isSelected={selectedQueries.includes(id as string)}
hasQueriesSelected={selectedQueries.length > 0}
onSelectQuery={() => onSelectQuery(id)}
onDeleteQuery={postDeleteCleanup}
/>
)
})}
</div>
</div>
</div>
) : null}
) : null}
{personalSnippets.length > 0 ? (
<div className="editor-product-menu">
<Menu.Group title="Your queries" />
<div className="space-y-1 pb-8">
{personalSnippets.map((tabInfo) => {
const { id } = tabInfo || {}
return (
<QueryItem
key={id}
tabInfo={tabInfo}
isSelected={selectedQueries.includes(id as string)}
hasQueriesSelected={selectedQueries.length > 0}
onSelectQuery={() => onSelectQuery(id)}
onDeleteQuery={postDeleteCleanup}
/>
)
})}
</div>
</div>
) : null}
</div>
</div>
) : (
<div></div>
)}
</Menu>
</div>
<ConfirmationModal
header="Confirm to delete query"
buttonLabel="Delete query"
buttonLoadingLabel="Deleting query"
size="medium"
loading={isDeleting}
visible={showDeleteModal}
onSelectConfirm={onConfirmDelete}
onSelectCancel={() => setShowDeleteModal(false)}
>
<Modal.Content>
<div className="my-6">
<div className="text-sm text-foreground-light grid gap-4">
<div className="grid gap-y-4">
<Alert_Shadcn_ variant="destructive">
<WarningIcon />
<AlertTitle_Shadcn_>This action cannot be undone</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
The selected SQL snippets cannot be recovered once deleted
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
<p>
Are you sure you want to delete the selected {selectedQueries.length} quer
{selectedQueries.length > 1 ? 'ies' : 'y'}?
</p>
</div>
</div>
</div>
) : (
<div></div>
)}
</Menu>
</div>
</Modal.Content>
</ConfirmationModal>
</>
)
})
@@ -13,13 +13,13 @@ const FileExplorerHeaderSelection = () => {
} = storageExplorerStore
return (
<div className="z-10 flex h-[40px] items-center rounded-t-md bg-brand-400 px-2 py-1 shadow [[data-theme*=dark]_&]:bg-brand-600">
<div className="z-10 flex h-[40px] items-center rounded-t-md bg-brand-400 px-2 py-1 shadow [[data-theme*=dark]_&]:bg-brand-500">
<Button
icon={<IconX size={16} strokeWidth={2} />}
type="text"
onClick={() => clearSelectedItems()}
/>
<div className="ml-4 flex items-center space-x-3">
<div className="ml-1 flex items-center space-x-3">
<p className="mb-0 text-sm text-foreground">
<span style={{ fontVariantNumeric: 'tabular-nums' }}>{selectedItems.length}</span> items
selected
@@ -37,7 +37,7 @@ const FileExplorerHeaderSelection = () => {
>
Download
</Button>
<div className="border-r border-green-800 py-3 opacity-50" />
<div className="border-r border-green-900 py-3 opacity-50" />
<Button
icon={<IconTrash2 size={16} strokeWidth={2} />}
type="primary"