remove explorer files

This commit is contained in:
Jonathan Summers-Muir committed 2024-12-11 21:35:56 +08:00
1 parent 691247d296
commit 13ec3b1584
15 files changed
+5 -2303

No files matched your search

@@ -40,7 +40,7 @@ import {
} from 'ui-patterns/InnerSideMenu'
import { useProjectContext } from '../ProjectLayout/ProjectContext'
import EntityListItem from './EntityListItem'
import { TableMenuEmptyState } from '../explorer/TableMenuEmptyState'
import { TableMenuEmptyState } from './TableMenuEmptyState'
import { SkeletonMenuList } from 'components/editor-menu-list-skeleton'
const TableEditorMenu = () => {
@@ -40,7 +40,7 @@ export const EditorBaseLayout = ({ children, ...props }: ExplorerLayoutProps) =>
)}
>
{hideTabs && <CollapseButton hideTabs={hideTabs} />}
{!hideTabs && <ExplorerTabs storeKey="explorer" />}
{!hideTabs && <ExplorerTabs />}
</div>
) : null}
<div className="h-full">{children}</div>
@@ -1,449 +0,0 @@
import * as Tooltip from '@radix-ui/react-tooltip'
import saveAs from 'file-saver'
import {
Copy,
Download,
Edit,
Eye,
Lock,
MoreHorizontal,
Table2,
Trash,
Unlock,
} from 'lucide-react'
import Link from 'next/link'
import Papa from 'papaparse'
import { toast } from 'sonner'
import { IS_PLATFORM } from 'common'
import {
MAX_EXPORT_ROW_COUNT,
MAX_EXPORT_ROW_COUNT_MESSAGE,
} from 'components/grid/components/header/Header'
import { parseSupaTable } from 'components/grid/SupabaseGrid.utils'
import { Markdown } from 'components/interfaces/Markdown'
import {
formatTableRowsToSQL,
getEntityLintDetails,
} from 'components/interfaces/TableGridEditor/TableEntity.utils'
import type { ItemRenderer } from 'components/ui/InfiniteList'
import { ENTITY_TYPE } from 'data/entity-types/entity-type-constants'
import { Entity } from 'data/entity-types/entity-types-infinite-query'
import { useProjectLintsQuery } from 'data/lint/lint-query'
import { EditorTablePageLink } from 'data/prefetchers/project.$ref.editor.$id'
import { getTableEditor } from 'data/table-editor/table-editor-query'
import { isTableLike } from 'data/table-editor/table-editor-types'
import { fetchAllTableRows } from 'data/table-rows/table-rows-query'
import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState'
import { useTableEditorStateSnapshot } from 'state/table-editor'
import {
cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
TreeViewItemVariant,
} from 'ui'
import { useProjectContext } from '../ProjectLayout/ProjectContext'
import { getTabsStore } from 'state/tabs'
import { useSnapshot } from 'valtio'
export interface EntityListItemProps {
id: number | string
projectRef: string
isLocked: boolean
isActive?: boolean
}
const EntityListItem: ItemRenderer<Entity, EntityListItemProps> = ({
id,
projectRef,
item: entity,
isLocked,
isActive: _isActive,
}) => {
const { project } = useProjectContext()
const snap = useTableEditorStateSnapshot()
const { selectedSchema } = useQuerySchemaState()
const isActive = _isActive ?? Number(id) === entity.id
const { data: lints = [] } = useProjectLintsQuery({
projectRef: project?.ref,
})
const tableHasLints: boolean = getEntityLintDetails(
entity.name,
'rls_disabled_in_public',
['ERROR'],
lints,
selectedSchema
).hasLint
const viewHasLints: boolean = getEntityLintDetails(
entity.name,
'security_definer_view',
['ERROR', 'WARN'],
lints,
selectedSchema
).hasLint
const materializedViewHasLints: boolean = getEntityLintDetails(
entity.name,
'materialized_view_in_api',
['ERROR', 'WARN'],
lints,
selectedSchema
).hasLint
const formatTooltipText = (entityType: string) => {
return Object.entries(ENTITY_TYPE)
.find(([, value]) => value === entityType)?.[0]
?.toLowerCase()
?.split('_')
?.join(' ')
}
const exportTableAsCSV = async () => {
if (IS_PLATFORM && !project?.connectionString) {
return console.error('Connection string is required')
}
const toastId = toast.loading(`Exporting ${entity.name} as CSV...`)
try {
const table = await getTableEditor({
id: entity.id,
projectRef,
connectionString: project?.connectionString,
})
if (isTableLike(table) && table.live_rows_estimate > MAX_EXPORT_ROW_COUNT) {
return toast.error(
<Markdown content={MAX_EXPORT_ROW_COUNT_MESSAGE} className="text-foreground" />,
{ id: toastId }
)
}
const supaTable = table && parseSupaTable(table)
if (!supaTable) {
return toast.error(`Failed to export table: ${entity.name}`, { id: toastId })
}
const rows = await fetchAllTableRows({
projectRef,
connectionString: project?.connectionString,
table: supaTable,
})
const formattedRows = rows.map((row) => {
const formattedRow = row
Object.keys(row).map((column) => {
if (typeof row[column] === 'object' && row[column] !== null)
formattedRow[column] = JSON.stringify(formattedRow[column])
})
return formattedRow
})
if (formattedRows.length > 0) {
const csv = Papa.unparse(formattedRows, {
columns: supaTable.columns.map((column) => column.name),
})
const csvData = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
saveAs(csvData, `${entity!.name}_rows.csv`)
}
toast.success(`Successfully exported ${entity.name} as CSV`, { id: toastId })
} catch (error: any) {
toast.error(`Failed to export table: ${error.message}`, { id: toastId })
}
}
const exportTableAsSQL = async () => {
if (IS_PLATFORM && !project?.connectionString) {
return console.error('Connection string is required')
}
const toastId = toast.loading(`Exporting ${entity.name} as SQL...`)
try {
const table = await getTableEditor({
id: entity.id,
projectRef,
connectionString: project?.connectionString,
})
if (isTableLike(table) && table.live_rows_estimate > MAX_EXPORT_ROW_COUNT) {
return toast.error(
<Markdown content={MAX_EXPORT_ROW_COUNT_MESSAGE} className="text-foreground" />,
{ id: toastId }
)
}
const supaTable = table && parseSupaTable(table)
if (!supaTable) {
return toast.error(`Failed to export table: ${entity.name}`, { id: toastId })
}
const rows = await fetchAllTableRows({
projectRef,
connectionString: project?.connectionString,
table: supaTable,
})
const formattedRows = rows.map((row) => {
const formattedRow = row
Object.keys(row).map((column) => {
if (typeof row[column] === 'object' && row[column] !== null)
formattedRow[column] = JSON.stringify(formattedRow[column])
})
return formattedRow
})
if (formattedRows.length > 0) {
const sqlStatements = formatTableRowsToSQL(supaTable, formattedRows)
const sqlData = new Blob([sqlStatements], { type: 'text/sql;charset=utf-8;' })
saveAs(sqlData, `${entity!.name}_rows.sql`)
}
toast.success(`Successfully exported ${entity.name} as SQL`, { id: toastId })
} catch (error: any) {
toast.error(`Failed to export table: ${error.message}`, { id: toastId })
}
}
const EntityTooltipTrigger = ({ entity }: { entity: Entity }) => {
let tooltipContent = null
switch (entity.type) {
case ENTITY_TYPE.TABLE:
if (tableHasLints) {
tooltipContent = 'RLS Disabled'
}
break
case ENTITY_TYPE.VIEW:
if (viewHasLints) {
tooltipContent = 'Security Definer view'
}
break
case ENTITY_TYPE.MATERIALIZED_VIEW:
if (materializedViewHasLints) {
tooltipContent = 'Security Definer view'
}
break
case ENTITY_TYPE.FOREIGN_TABLE:
tooltipContent = 'RLS is not enforced on foreign tables'
break
default:
break
}
if (tooltipContent) {
return (
<Tooltip.Root delayDuration={0} disableHoverableContent={true}>
<Tooltip.Trigger className="min-w-4" asChild>
<Unlock
size={14}
strokeWidth={2}
className={cn('min-w-4', isActive ? 'text-warning-600' : 'text-warning-500')}
/>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
side="bottom"
className={[
'rounded bg-alternative py-1 px-2 leading-none shadow',
'border border-background',
'text-xs text-foreground',
].join(' ')}
>
<Tooltip.Arrow className="radix-tooltip-arrow" />
{tooltipContent}
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
)
}
return null
}
const tabsStore = getTabsStore(projectRef)
const tabs = useSnapshot(tabsStore)
const isOpened = Object.values(tabs.tabsMap).some((tab) => tab.metadata?.tableId === entity.id)
return (
<EditorTablePageLink
title={entity.name}
id={String(entity.id)}
href={`/project/${projectRef}/editor/${entity.id}?schema=${selectedSchema}`}
role="button"
aria-label={`View ${entity.name}`}
className={cn(TreeViewItemVariant({ isSelected: isActive, isOpened }), 'px-4')}
>
<>
{isActive && <div className="absolute left-0 h-full w-0.5 bg-foreground" />}
<Tooltip.Root delayDuration={0} disableHoverableContent={true}>
<Tooltip.Trigger className="min-w-4" asChild>
{entity.type === ENTITY_TYPE.TABLE ? (
<Table2
size={15}
strokeWidth={1.5}
className={cn(
'text-foreground-muted group-hover:text-foreground-lighter',
isActive && 'text-foreground-lighter',
'transition-colors'
)}
/>
) : entity.type === ENTITY_TYPE.VIEW ? (
<Eye
size={15}
strokeWidth={1.5}
className={cn(
'text-foreground-muted group-hover:text-foreground-lighter',
isActive && 'text-foreground-lighter',
'transition-colors'
)}
/>
) : (
<div
className={cn(
'flex items-center justify-center text-xs h-4 w-4 rounded-[2px] font-bold',
entity.type === ENTITY_TYPE.FOREIGN_TABLE && 'text-yellow-900 bg-yellow-500',
entity.type === ENTITY_TYPE.MATERIALIZED_VIEW && 'text-purple-1000 bg-purple-500',
entity.type === ENTITY_TYPE.PARTITIONED_TABLE &&
'text-foreground-light bg-border-stronger'
)}
>
{Object.entries(ENTITY_TYPE)
.find(([, value]) => value === entity.type)?.[0]?.[0]
?.toUpperCase()}
</div>
)}
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
side="bottom"
className={[
'rounded bg-alternative py-1 px-2 leading-none shadow',
'border border-background',
'text-xs text-foreground capitalize',
].join(' ')}
>
<Tooltip.Arrow className="radix-tooltip-arrow" />
{formatTooltipText(entity.type)}
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
<div
className={cn(
'truncate',
'overflow-hidden text-ellipsis whitespace-nowrap flex items-center gap-2 relative w-full',
isActive && 'text-foreground'
)}
>
<span
className={cn(
isActive ? 'text-foreground' : 'text-foreground-light group-hover:text-foreground',
'text-sm',
'transition',
'truncate'
)}
>
{entity.name}
</span>
<EntityTooltipTrigger entity={entity} />
</div>
{entity.type === ENTITY_TYPE.TABLE && isActive && !isLocked && (
<DropdownMenu>
<DropdownMenuTrigger className="text-foreground-lighter transition-all hover:text-foreground data-[state=open]:text-foreground">
<MoreHorizontal size={14} strokeWidth={2} />
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="start" className="w-44">
<DropdownMenuItem
key="edit-table"
className="space-x-2"
onClick={(e) => {
e.stopPropagation()
snap.onEditTable()
}}
>
<Edit size={12} />
<span>Edit Table</span>
</DropdownMenuItem>
<DropdownMenuItem
key="duplicate-table"
className="space-x-2"
onClick={(e) => {
e.stopPropagation()
snap.onDuplicateTable()
}}
>
<Copy size={12} />
<span>Duplicate Table</span>
</DropdownMenuItem>
<DropdownMenuItem key="view-policies" className="space-x-2" asChild>
<Link
key="view-policies"
href={`/project/${projectRef}/auth/policies?schema=${selectedSchema}&search=${entity.id}`}
>
<Lock size={12} />
<span>View Policies</span>
</Link>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger className="gap-x-2">
<Download size={12} />
Export Data
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem
key="download-table-csv"
className="space-x-2"
onClick={(e) => {
e.stopPropagation()
exportTableAsCSV()
}}
>
<span>Export table as CSV</span>
</DropdownMenuItem>
<DropdownMenuItem
key="download-table-sql"
className="gap-x-2"
onClick={(e) => {
e.stopPropagation()
exportTableAsSQL()
}}
>
<span>Export table as SQL</span>
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem
key="delete-table"
className="gap-x-2"
onClick={(e) => {
e.stopPropagation()
snap.onDeleteTable()
}}
>
<Trash size={12} />
<span>Delete Table</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</>
</EditorTablePageLink>
)
}
export default EntityListItem
@@ -1,153 +0,0 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { Edit2, FilePlus, FolderPlus, Plus } from 'lucide-react'
import { useRouter } from 'next/router'
import { useState } from 'react'
import { toast } from 'sonner'
import { useParams } from 'common'
import { untitledSnippetTitle } from 'components/interfaces/SQLEditor/SQLEditor.constants'
import { createSqlSnippetSkeletonV2 } from 'components/interfaces/SQLEditor/SQLEditor.utils'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedProject } from 'hooks/misc/useSelectedProject'
import { uuidv4 } from 'lib/helpers'
import { useProfile } from 'lib/profile'
import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2'
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from 'ui'
import {
InnerSideBarFilterSearchInput,
InnerSideBarFilterSortDropdown,
InnerSideBarFilterSortDropdownItem,
InnerSideBarFilters,
InnerSideMenuItem,
} from 'ui-patterns/InnerSideMenu'
import { SQLEditorNav as SQLEditorNavV2 } from './SQLEditorNavV2/SQLEditorNav'
interface SQLEditorMenuProps {
onViewOngoingQueries: () => void
}
export const SQLEditorMenu = ({ onViewOngoingQueries }: SQLEditorMenuProps) => {
const router = useRouter()
const { profile } = useProfile()
const project = useSelectedProject()
const { ref } = useParams()
const snapV2 = useSqlEditorV2StateSnapshot()
const [searchText, setSearchText] = useState('')
const canCreateSQLSnippet = useCheckPermissions(PermissionAction.CREATE, 'user_content', {
resource: { type: 'sql', owner_id: profile?.id },
subject: { id: profile?.id },
})
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 () => {
if (!ref) return console.error('Project ref is required')
if (!project) 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 = 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}`)
setSearchText('')
} catch (error: any) {
toast.error(`Failed to create new query: ${error.message}`)
}
}
return (
<div className="h-[400px] flex flex-col">
<div className="mt-4 mb-2 flex flex-col gap-y-4">
<div className="px-4 flex justify-between items-center">
<h2 className="text-sm">Workbook</h2>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="default" icon={<Edit2 />} className="w-[26px]" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="bottom" className="w-48">
<DropdownMenuItem className="gap-x-2" onClick={() => handleNewQuery()}>
<FilePlus size={14} />
Create a new snippet
</DropdownMenuItem>
<DropdownMenuItem className="gap-x-2" onClick={() => createNewFolder()}>
<FolderPlus size={14} />
Create a new folder
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="mx-4 flex items-center justify-between gap-x-2">
<InnerSideBarFilters className="w-full p-0 gap-0">
<InnerSideBarFilterSearchInput
name="search-queries"
placeholder="Search queries..."
aria-labelledby="Search queries"
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
>
<InnerSideBarFilterSortDropdown
value={snapV2.order}
onValueChange={(value: any) => snapV2.setOrder(value)}
>
<InnerSideBarFilterSortDropdownItem key="name" value="name">
Alphabetical
</InnerSideBarFilterSortDropdownItem>
<InnerSideBarFilterSortDropdownItem key="inserted_at" value="inserted_at">
Created At
</InnerSideBarFilterSortDropdownItem>
</InnerSideBarFilterSortDropdown>
</InnerSideBarFilterSearchInput>
</InnerSideBarFilters>
</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> */}
<SQLEditorNavV2 searchText={searchText} />
</div>
{/* <div className="p-4 border-t sticky bottom-0 bg-studio">
<Button block type="default" onClick={onViewOngoingQueries}>
View running queries
</Button>
</div> */}
</div>
)
}
@@ -1,729 +0,0 @@
import { Eye, EyeOffIcon, Heart, Pointer, Unlock } from 'lucide-react'
import { useRouter } from 'next/router'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { useParams } from 'common'
import DownloadSnippetModal from 'components/interfaces/SQLEditor/DownloadSnippetModal'
import { MoveQueryModal } from 'components/interfaces/SQLEditor/MoveQueryModal'
import RenameQueryModal from 'components/interfaces/SQLEditor/RenameQueryModal'
import { untitledSnippetTitle } from 'components/interfaces/SQLEditor/SQLEditor.constants'
import { createSqlSnippetSkeletonV2 } from 'components/interfaces/SQLEditor/SQLEditor.utils'
import { useContentCountQuery } from 'data/content/content-count-query'
import { useContentDeleteMutation } from 'data/content/content-delete-mutation'
import { getContentById } from 'data/content/content-id-query'
import { useSQLSnippetFoldersDeleteMutation } from 'data/content/sql-folders-delete-mutation'
import {
Snippet,
SnippetDetail,
SnippetFolder,
getSQLSnippetFolders,
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,
useSnippetFolders,
useSnippets,
useSqlEditorV2StateSnapshot,
} from 'state/sql-editor-v2'
import { Separator, Skeleton, TreeView, TreeViewItem } from 'ui'
import {
InnerSideBarEmptyPanel,
InnerSideMenuCollapsible,
InnerSideMenuCollapsibleContent,
InnerSideMenuCollapsibleTrigger,
InnerSideMenuSeparator,
} from 'ui-patterns'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { ROOT_NODE, formatFolderResponseForTreeView } from './SQLEditorNav.utils'
import { SQLEditorTreeViewItem } from './SQLEditorTreeViewItem'
import { getTabsStore } from 'state/tabs'
interface SQLEditorNavProps {
searchText: string
}
export const SQLEditorNav = ({ searchText: _searchText }: SQLEditorNavProps) => {
const searchText = _searchText.trim()
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 [showSharedSnippets, setShowSharedSnippets] = useState(false)
const [showPrivateSnippets, setShowPrivateSnippets] = useState(true)
const [defaultExpandedFolderIds, setDefaultExpandedFolderIds] = useState<string[]>()
const [selectedSnippets, setSelectedSnippets] = useState<Snippet[]>([])
const [selectedSnippetToShare, setSelectedSnippetToShare] = useState<Snippet>()
const [selectedSnippetToUnshare, setSelectedSnippetToUnshare] = useState<Snippet>()
const [selectedSnippetToRename, setSelectedSnippetToRename] = useState<Snippet>()
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
)
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 })
// =================================
// [Joshen] React Queries
// =================================
useSQLSnippetFoldersQuery(
{ projectRef },
{
refetchOnWindowFocus: false,
staleTime: 5 * 60 * 1000, // 5 minutes
onSuccess: (data) => {
if (projectRef !== undefined) {
snapV2.initializeRemoteSnippets({ projectRef, data, sort })
}
},
}
)
useSqlSnippetsQuery(projectRef, {
onSuccess(data) {
if (projectRef !== undefined) {
const favoriteSnippets = data.snippets.filter((snippet) => snippet.content.favorite)
snapV2.initializeFavoriteSnippets({ projectRef, snippets: favoriteSnippets })
}
},
})
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')) {
postDeleteCleanup(data.ids)
} else {
toast.error(`Failed to delete query: ${error.message}`)
}
},
})
const { mutate: deleteFolder, isLoading: isDeletingFolder } = useSQLSnippetFoldersDeleteMutation({
onSuccess: (_, vars) => {
toast.success('Successfully deleted folder')
const { ids } = vars
snapV2.removeFolder(ids[0])
setSelectedFolderToDelete(undefined)
},
})
// =================================
// [Joshen] UI functions
// =================================
const postDeleteCleanup = (ids: string[]) => {
setShowDeleteModal(false)
setSelectedSnippets([])
const existingSnippetIds = Object.keys(snapV2.snippets).filter((x) => !ids.includes(x))
if (existingSnippetIds.length === 0) {
router.push(`/project/${projectRef}/sql`)
} else if (ids.includes(id as string)) {
router.push(`/project/${projectRef}/sql/${existingSnippetIds[0]}`)
}
if (ids.length > 0) ids.forEach((id) => snapV2.removeSnippet(id))
}
const onConfirmDelete = () => {
if (!projectRef) return console.error('Project ref is required')
deleteContent(
{ projectRef, ids: selectedSnippets.map((x) => x.id) },
{
onSuccess: (data) => {
toast.success(
`Successfully deleted ${selectedSnippets.length.toLocaleString()} quer${selectedSnippets.length > 1 ? 'ies' : 'y'}`
)
postDeleteCleanup(data)
},
}
)
}
const onConfirmShare = () => {
if (!selectedSnippetToShare) return console.error('Snippet ID is required')
snapV2.shareSnippet(selectedSnippetToShare.id, 'project')
setSelectedSnippetToShare(undefined)
setShowSharedSnippets(true)
if (projectRef !== undefined) {
snapV2.setPrivateSnippetCount({
projectRef,
value: snapV2.privateSnippetCount[projectRef] - 1,
})
}
}
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) => {
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)) {
// Fetch the content first
const { content } = await getContentById({ projectRef, id: snippet.id })
sql = content.sql
} else {
sql = (snippet as SnippetDetail).content.sql
}
const snippetCopy = createSqlSnippetSkeletonV2({
id: uuidv4(),
name: snippet.name,
sql,
owner_id: profile?.id,
project_id: project?.id,
})
snapV2.addSnippet({ projectRef, snippet: snippetCopy })
snapV2.addNeedsSaving(snippetCopy.id!)
router.push(`/project/${projectRef}/sql/${snippetCopy.id}`)
}
const onConfirmDeleteFolder = async () => {
if (!projectRef) return console.error('Project ref is required')
if (selectedFolderToDelete === undefined) return console.error('No folder is selected')
const folderSnippets = contents.filter(
(content) => content.folder_id === selectedFolderToDelete.id
)
if (folderSnippets.length > 0) {
const ids = folderSnippets.map((x) => x.id)
deleteContent(
{ projectRef, ids },
{
onSuccess: () => {
ids.forEach((id) => snapV2.removeSnippet(id))
postDeleteCleanup(ids)
deleteFolder({ projectRef, ids: [selectedFolderToDelete?.id] })
},
}
)
} else {
deleteFolder({ projectRef, ids: [selectedFolderToDelete?.id] })
}
}
// [Joshen] Just FYI doing a controlled state instead of letting the TreeView component doing it because
// 1. There seems to be no way of accessing the internal state of the TreeView to retrieve the selected nodes
// 2. The component itself doesn't handle UUID for node IDs well - trying to multi select doesn't select the expected nodes
// 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 baseIndex = contentIds.indexOf(id as string)
const targetIndex = contentIds.indexOf(selectedId)
const floor = Math.min(baseIndex, targetIndex)
const ceiling = Math.max(baseIndex, targetIndex)
const _selectedSnippets = []
const sameFolder = contents[floor].folder_id === contents[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])
} else {
// [Joshen] Temp don't allow selecting across folders for now
// _selectedSnippets.push(contents[i])
}
}
setSelectedSnippets(_selectedSnippets)
}
// ======================================
// [Joshen] useEffects kept at the bottom
// ======================================
useEffect(() => {
const loadFolderContents = async (folderId: string) => {
const { contents } = await getSQLSnippetFolders({ projectRef, folderId })
if (projectRef) {
contents?.forEach((snippet) => snapV2.addSnippet({ projectRef, snippet }))
}
}
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])
useEffect(() => {
// Unselect all snippets whenever opening another snippet
setSelectedSnippets([])
}, [id])
return (
<>
{((numProjectSnippets === 0 && searchText.length === 0) || numProjectSnippets > 0) && (
<>
<InnerSideMenuCollapsible
open={showSharedSnippets}
onOpenChange={setShowSharedSnippets}
className="px-0"
>
<InnerSideMenuCollapsibleTrigger
title={`Shared ${numProjectSnippets > 0 ? ` (${numProjectSnippets})` : ''}`}
/>
<InnerSideMenuCollapsibleContent className="group-data-[state=open]:pt-2">
{numProjectSnippets === 0 ? (
<InnerSideBarEmptyPanel
className="mx-3"
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 }) => {
const tabsStore = getTabsStore(projectRef)
const isOpened = Object.values(tabsStore.tabsMap).some(
(tab) => tab.metadata?.sqlId === element.id || tab.id === element.id
)
return (
<SQLEditorTreeViewItem
{...props}
element={element}
isOpened={isOpened}
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)
}}
/>
)
}}
/>
)}
</InnerSideMenuCollapsibleContent>
</InnerSideMenuCollapsible>
</>
)}
{((numFavoriteSnippets === 0 && searchText.length === 0) || numFavoriteSnippets > 0) && (
<>
<InnerSideMenuCollapsible
className="px-0"
open={showFavouriteSnippets}
onOpenChange={setShowFavouriteSnippets}
>
<InnerSideMenuCollapsibleTrigger
title={`Favorites ${numFavoriteSnippets > 0 ? ` (${numFavoriteSnippets})` : ''}`}
/>
<InnerSideMenuCollapsibleContent className="group-data-[state=open]:pt-2">
{numFavoriteSnippets === 0 ? (
<InnerSideBarEmptyPanel
title="No favorite queries"
className="mx-3"
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 }) => {
const tabsStore = getTabsStore(projectRef)
const isOpened = Object.values(tabsStore.tabsMap).some(
(tab) => tab.metadata?.sqlId === element.id || tab.id === element.id
)
return (
<SQLEditorTreeViewItem
{...props}
element={element}
isOpened={isOpened}
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)
}}
/>
)
}}
/>
)}
</InnerSideMenuCollapsibleContent>
</InnerSideMenuCollapsible>
</>
)}
<InnerSideMenuCollapsible
open={showPrivateSnippets}
onOpenChange={setShowPrivateSnippets}
className="px-0"
>
<InnerSideMenuCollapsibleTrigger
title={`PRIVATE
${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>
</>
) : folders.length === 0 && numPrivateSnippets === 0 ? (
<InnerSideBarEmptyPanel
className="mx-3 px-4"
title="No prviate queries created yet"
description="Queries will be automatically saved once you start writing in the editor"
>
<div className="top-0 left-6 flex flex-col opacity-50 cursor-not-allowed w-60 bg-dash-sidebar h-content -mb-4 mt-3 pointer-events-none">
<div className="relative h-content">
<div className="absolute inset-0 pointer-events-none z-10">
<div className="absolute inset-0 bg-gradient-to-t from-transparent from-80% to-100% to-background-surface-100 dark:to-background-surface-75" />
<div className="absolute inset-0 bg-gradient-to-r from-transparent from-50% to-100% to-background-surface-100 dark:to-background-surface-75" />
</div>
<div className="absolute left-[150px] bottom-[21px] text-foreground-muted z-10 pointer-events-none">
<Pointer size={16} className="text-foreground-light" strokeWidth={1.5} />
</div>
{[...Array(4)].map((_, i) => (
<div className="border-l pointer-events-none">
<TreeViewItem
isSelected={i === 2}
key={`dummy-${i + 1}`}
id={`dummy-${i + 1}`}
name={`dummy_query_${i + 1}`}
level={1}
xPadding={0}
/>
</div>
))}
</div>
</div>
</InnerSideBarEmptyPanel>
) : (
<TreeView
multiSelect
togglableSelect
clickAction="EXCLUSIVE_SELECT"
data={privateSnippetsTreeState}
selectedIds={selectedSnippets.map((x) => x.id)}
aria-label="private-snippets"
expandedIds={defaultExpandedFolderIds}
nodeRenderer={({ element, ...props }) => {
const tabsStore = getTabsStore(projectRef)
const isOpened = Object.values(tabsStore.tabsMap).some(
(tab) => tab.metadata?.sqlId === element.id || tab.id === element.id
)
return (
<SQLEditorTreeViewItem
{...props}
isOpened={isOpened}
element={element}
isMultiSelected={selectedSnippets.length > 1}
status={props.isBranch ? snapV2.folders[element.id].status : 'idle'}
onMultiSelect={onMultiSelect}
onSelectCreate={() => {
if (profile && project) {
const snippet = createSqlSnippetSkeletonV2({
id: uuidv4(),
name: untitledSnippetTitle,
owner_id: profile?.id,
project_id: project?.id,
folder_id: element.id as string,
sql: '',
})
snapV2.addSnippet({ projectRef: project.ref, snippet })
router.push(`/project/${projectRef}/sql/${snippet.id}`)
}
}}
onSelectDelete={() => {
if (props.isBranch) {
setSelectedFolderToDelete(element.metadata as SnippetFolder)
} else {
setShowDeleteModal(true)
if (selectedSnippets.length === 0) {
setSelectedSnippets([element.metadata as unknown as Snippet])
}
}
}}
onSelectRename={() => {
if (props.isBranch) {
snapV2.editFolder(element.id as string)
} else {
setShowRenameModal(true)
setSelectedSnippetToRename(element.metadata as Snippet)
}
}}
onSelectMove={() => {
setShowMoveModal(true)
if (selectedSnippets.length === 0) {
setSelectedSnippets([element.metadata as Snippet])
}
}}
onSelectDownload={() =>
setSelectedSnippetToDownload(element.metadata as Snippet)
}
onSelectShare={() => setSelectedSnippetToShare(element.metadata as Snippet)}
onEditSave={(name: string) => {
// [Joshen] Inline editing only for folders for now
if (name.length === 0 && element.id === 'new-folder') {
snapV2.removeFolder(element.id as string)
} else if (name.length > 0) {
snapV2.saveFolder({ id: element.id as string, name })
}
}}
/>
)
}}
/>
)}
</InnerSideMenuCollapsibleContent>
</InnerSideMenuCollapsible>
<Separator />
<RenameQueryModal
snippet={selectedSnippetToRename}
visible={showRenameModal}
onCancel={() => setShowRenameModal(false)}
onComplete={() => setShowRenameModal(false)}
/>
<MoveQueryModal
snippets={selectedSnippets}
visible={showMoveModal}
onClose={() => {
setShowMoveModal(false)
setSelectedSnippets([])
}}
/>
<DownloadSnippetModal
id={selectedSnippetToDownload?.id ?? ''}
visible={selectedSnippetToDownload !== undefined}
onCancel={() => setSelectedSnippetToDownload(undefined)}
/>
<ConfirmationModal
size="medium"
title={`Confirm to share query: ${selectedSnippetToShare?.name}`}
confirmLabel="Share query"
confirmLabelLoading="Sharing query"
visible={selectedSnippetToShare !== undefined}
onCancel={() => setSelectedSnippetToShare(undefined)}
onConfirm={onConfirmShare}
alert={{
title: 'This SQL query will become public to all team members',
description: 'Anyone with access to the project can view it',
}}
>
<ul className="text-sm text-foreground-light space-y-5">
<li className="flex gap-3 items-center">
<Eye size={16} />
<span>Project members will have read-only access to this query.</span>
</li>
<li className="flex gap-3 items-center">
<Unlock size={16} />
<span>Anyone will be able to duplicate it to their personal snippets.</span>
</li>
</ul>
</ConfirmationModal>
<ConfirmationModal
size="medium"
title={`Confirm to unshare query: ${selectedSnippetToUnshare?.name}`}
confirmLabel="Unshare query"
confirmLabelLoading="Unsharing query"
visible={selectedSnippetToUnshare !== undefined}
onCancel={() => setSelectedSnippetToUnshare(undefined)}
onConfirm={onConfirmUnshare}
alert={{
title: 'This SQL query will no longer be public to all team members',
description: 'Only you will have access to this query',
}}
>
<ul className="text-sm text-foreground-light space-y-5">
<li className="flex gap-3">
<EyeOffIcon />
<span>Project members will no longer be able to view this query.</span>
</li>
</ul>
</ConfirmationModal>
<ConfirmationModal
size="small"
title={`Confirm to delete ${selectedSnippets.length === 1 ? 'query' : `${selectedSnippets.length.toLocaleString()} quer${selectedSnippets.length > 1 ? 'ies' : 'y'}`}`}
confirmLabel={`Delete ${selectedSnippets.length.toLocaleString()} quer${selectedSnippets.length > 1 ? 'ies' : 'y'}`}
confirmLabelLoading="Deleting query"
loading={isDeleting}
visible={showDeleteModal}
variant="destructive"
onCancel={() => {
setShowDeleteModal(false)
setSelectedSnippets([])
}}
onConfirm={onConfirmDelete}
alert={
(selectedSnippets[0]?.visibility as unknown as string) === 'project'
? {
title: 'This SQL snippet will be lost forever',
description:
'Deleting this query will remove it for all members of the project team.',
}
: undefined
}
>
<p className="text-sm">
This action cannot be undone.{' '}
{selectedSnippets.length === 1
? `Are you sure you want to delete '${selectedSnippets[0]?.name}'?`
: `Are you sure you want to delete the selected ${selectedSnippets.length} quer${selectedSnippets.length > 1 ? 'ies' : 'y'}?`}
</p>
</ConfirmationModal>
<ConfirmationModal
size="small"
title="Confirm to delete folder"
confirmLabel="Delete folder"
confirmLabelLoading="Deleting folder"
loading={isDeletingFolder}
visible={selectedFolderToDelete !== undefined}
variant="destructive"
onCancel={() => setSelectedFolderToDelete(undefined)}
onConfirm={onConfirmDeleteFolder}
alert={{
title: 'This action cannot be undone',
description:
'All SQL snippets within the folder will be permanently removed, and cannot be recovered.',
}}
>
<p className="text-sm">
Are you sure you want to delete the folder '{selectedFolderToDelete?.name}'?
</p>
</ConfirmationModal>
</>
)
}
@@ -1,54 +0,0 @@
import { SnippetFolderResponse } from 'data/content/sql-folders-query'
export interface TreeViewItemProps {
id: string | number
name: string
parent: number | string | null
children: any[]
metadata?: any
}
export const ROOT_NODE = { 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,
export const formatFolderResponseForTreeView = (
response?: SnippetFolderResponse
): TreeViewItemProps[] => {
if (response === undefined) return [ROOT_NODE]
const { folders, contents } = response
const formattedFolders =
folders?.map((folder) => {
const { id, name } = folder
return {
id,
name,
parent: 0,
isBranch: true,
children:
contents?.filter((content) => content.folder_id === id).map((content) => content.id) ??
[],
metadata: folder,
}
}) || []
const formattedContents =
contents?.map((content) => {
const { id, name, folder_id } = content
return { id, name, parent: folder_id ?? 0, children: [], metadata: content }
}) || []
const root = {
id: 0,
name: '',
parent: null,
children: [
...(folders || [])?.map((folder) => folder.id),
...(contents || []).filter((content) => !content.folder_id)?.map((content) => content.id),
],
}
return [root, ...formattedFolders, ...formattedContents]
}
@@ -1,298 +0,0 @@
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 { IS_PLATFORM } from 'common'
import { useParams } from 'common/hooks/useParams'
import { getSQLSnippetFolders } from 'data/content/sql-folders-query'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useProfile } from 'lib/profile'
import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2'
import {
ContextMenuContent_Shadcn_,
ContextMenuItem_Shadcn_,
ContextMenuSeparator_Shadcn_,
ContextMenuTrigger_Shadcn_,
ContextMenu_Shadcn_,
TreeViewItem,
} from 'ui'
interface SQLEditorTreeViewItemProps {
element: any
level: number
isBranch: boolean
isSelected: boolean
isOpened?: boolean
isExpanded?: boolean
isMultiSelected?: boolean
status?: 'editing' | 'saving' | 'idle'
getNodeProps: () => any
onSelectCreate?: () => void
onSelectDelete?: () => void
onSelectRename?: () => void
onSelectMove?: () => void
onSelectShare?: () => void
onSelectUnshare?: () => void
onSelectDownload?: () => void
onSelectCopyPersonal?: () => void
onSelectDeleteFolder?: () => void
onEditSave?: (name: string) => void
onMultiSelect?: (id: string) => void
}
export const SQLEditorTreeViewItem = ({
element,
isBranch,
isOpened,
isExpanded,
level,
status,
isSelected,
isMultiSelected,
getNodeProps,
onSelectCreate,
onSelectDelete,
onSelectRename,
onSelectMove,
onSelectShare,
onSelectUnshare,
onSelectDownload,
onSelectCopyPersonal,
onEditSave,
onMultiSelect,
}: SQLEditorTreeViewItemProps) => {
const router = useRouter()
const { id, ref } = 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'
const isEditing = status === 'editing'
const isSaving = status === 'saving'
const canCreateSQLSnippet = useCheckPermissions(PermissionAction.CREATE, 'user_content', {
resource: { type: 'sql', owner_id: profile?.id },
subject: { id: profile?.id },
})
// [Joshen] Folder contents are loaded on demand too
const onOpenFolder = async (id: string) => {
if (!ref) return console.error('Project ref is required')
try {
setIsFetching(true)
const { contents } = await getSQLSnippetFolders({ projectRef: ref, folderId: id })
contents?.forEach((snippet) => {
snapV2.addSnippet({ projectRef: ref, snippet })
})
} catch (error: any) {
toast.error(`Failed to retrieve folder contents: ${error.message}`)
} finally {
setIsFetching(false)
}
}
return (
<>
<ContextMenu_Shadcn_ modal={false}>
<ContextMenuTrigger_Shadcn_ asChild>
<TreeViewItem
isOpened={isOpened}
level={level}
xPadding={16}
name={element.name}
className={className}
isExpanded={isExpanded}
isBranch={isBranch}
isSelected={isSelected || id === element.id}
isEditing={isEditing}
isLoading={isFetching || isSaving}
onEditSubmit={(value) => {
if (onEditSave !== undefined) onEditSave(value)
}}
onClick={(e) => {
if (!isBranch) {
if (!e.shiftKey) {
const schema = (router.query.schema as string) || 'public'
router.push(`/project/${ref}/sql/${element.id}?schema=${schema}`)
} else if (id !== 'new') {
onMultiSelect?.(element.id)
} else {
const schema = (router.query.schema as string) || 'public'
router.push(`/project/${ref}/sql/${element.id}?schema=${schema}`)
}
} else {
// Prevent expanding folder while editing text
// as the user may double click to select etc
if (isEditing) {
return
}
onClick(e)
if (!isExpanded) onOpenFolder(element.id)
}
}}
/>
</ContextMenuTrigger_Shadcn_>
<ContextMenuContent_Shadcn_ onCloseAutoFocus={(e) => e.stopPropagation()}>
{isBranch ? (
<>
{onSelectCreate !== undefined && (
<ContextMenuItem_Shadcn_
className="gap-x-2"
onSelect={() => onSelectCreate()}
onFocusCapture={(e) => e.stopPropagation()}
>
<Plus size={14} />
Create new snippet
</ContextMenuItem_Shadcn_>
)}
{onSelectRename !== undefined && isOwner && (
<ContextMenuItem_Shadcn_
className="gap-x-2"
onSelect={() => onSelectRename()}
onFocusCapture={(e) => e.stopPropagation()}
>
<Edit size={14} />
Rename folder
</ContextMenuItem_Shadcn_>
)}
{onSelectDelete !== undefined && isOwner && (
<>
<ContextMenuSeparator_Shadcn_ />
<ContextMenuItem_Shadcn_
className="gap-x-2"
onSelect={() => onSelectDelete()}
onFocusCapture={(e) => e.stopPropagation()}
>
<Trash size={14} />
Delete folder
</ContextMenuItem_Shadcn_>
</>
)}
</>
) : isMultiSelected ? (
<>
{onSelectMove !== undefined && (
<ContextMenuItem_Shadcn_
className="gap-x-2"
onSelect={() => onSelectMove()}
onFocusCapture={(e) => e.stopPropagation()}
>
<Move size={14} />
Move selected queries
</ContextMenuItem_Shadcn_>
)}
<ContextMenuSeparator_Shadcn_ />
{onSelectDelete !== undefined && (
<ContextMenuItem_Shadcn_
className="gap-x-2"
onSelect={() => onSelectDelete()}
onFocusCapture={(e) => e.stopPropagation()}
>
<Trash size={14} />
Delete selected queries
</ContextMenuItem_Shadcn_>
)}
</>
) : (
<>
<ContextMenuItem_Shadcn_
asChild
className="gap-x-2"
onSelect={() => {}}
onFocusCapture={(e) => e.stopPropagation()}
>
<Link href={`/project/${ref}/sql/${element.id}`} target="_blank" rel="noreferrer">
<ExternalLink size={14} />
Open in new tab
</Link>
</ContextMenuItem_Shadcn_>
<ContextMenuSeparator_Shadcn_ />
{onSelectRename !== undefined && isOwner && (
<ContextMenuItem_Shadcn_
className="gap-x-2"
onSelect={() => onSelectRename()}
onFocusCapture={(e) => e.stopPropagation()}
>
<Edit size={14} />
Rename query
</ContextMenuItem_Shadcn_>
)}
{onSelectMove !== undefined && isOwner && (
<ContextMenuItem_Shadcn_
className="gap-x-2"
onSelect={() => onSelectMove()}
onFocusCapture={(e) => e.stopPropagation()}
>
<Move size={14} />
Move query
</ContextMenuItem_Shadcn_>
)}
{onSelectShare !== undefined && !isSharedSnippet && canCreateSQLSnippet && (
<ContextMenuItem_Shadcn_
className="gap-x-2"
onSelect={() => onSelectShare()}
onFocusCapture={(e) => e.stopPropagation()}
>
<Share size={14} />
Share query with team
</ContextMenuItem_Shadcn_>
)}
{onSelectUnshare !== undefined && isSharedSnippet && isOwner && (
<ContextMenuItem_Shadcn_
className="gap-x-2"
onSelect={() => onSelectUnshare()}
onFocusCapture={(e) => e.stopPropagation()}
>
<Lock size={14} />
Unshare query with team
</ContextMenuItem_Shadcn_>
)}
{onSelectCopyPersonal !== undefined &&
isSharedSnippet &&
!isOwner &&
canCreateSQLSnippet && (
<ContextMenuItem_Shadcn_
className="gap-x-2"
onSelect={() => onSelectCopyPersonal()}
onFocusCapture={(e) => e.stopPropagation()}
>
<Copy size={14} />
Duplicate personal copy
</ContextMenuItem_Shadcn_>
)}
{onSelectDownload !== undefined && IS_PLATFORM && (
<ContextMenuItem_Shadcn_
className="gap-x-2"
onSelect={() => onSelectDownload()}
onFocusCapture={(e) => e.stopPropagation()}
>
<Download size={14} />
Download as migration file
</ContextMenuItem_Shadcn_>
)}
{onSelectDelete !== undefined && isOwner && (
<>
<ContextMenuSeparator_Shadcn_ />
<ContextMenuItem_Shadcn_ className="gap-x-2" onSelect={() => onSelectDelete()}>
<Trash size={14} />
Delete query
</ContextMenuItem_Shadcn_>
</>
)}
</>
)}
</ContextMenuContent_Shadcn_>
</ContextMenu_Shadcn_>
</>
)
}
@@ -1,326 +0,0 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { partition } from 'lodash'
import { Edit2, Filter, Pointer } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { useParams } from 'common'
import { ProtectedSchemaModal } from 'components/interfaces/Database/ProtectedSchemaWarning'
import AlertError from 'components/ui/AlertError'
import InfiniteList from 'components/ui/InfiniteList'
import SchemaSelector from 'components/ui/SchemaSelector'
import { useSchemasQuery } from 'data/database/schemas-query'
import { ENTITY_TYPE } from 'data/entity-types/entity-type-constants'
import { useEntityTypesQuery } from 'data/entity-types/entity-types-infinite-query'
import { useTableEditorQuery } from 'data/table-editor/table-editor-query'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useLocalStorage } from 'hooks/misc/useLocalStorage'
import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState'
import { EXCLUDED_SCHEMAS } from 'lib/constants/schemas'
import { useTableEditorStateSnapshot } from 'state/table-editor'
import {
Button,
Checkbox_Shadcn_,
Label_Shadcn_,
PopoverContent_Shadcn_,
PopoverTrigger_Shadcn_,
Popover_Shadcn_,
Skeleton,
} from 'ui'
import {
InnerSideBarEmptyPanel,
InnerSideBarFilterSearchInput,
InnerSideBarFilterSortDropdown,
InnerSideBarFilterSortDropdownItem,
InnerSideBarFilters,
} from 'ui-patterns/InnerSideMenu'
import { useProjectContext } from '../ProjectLayout/ProjectContext'
import EntityListItem from './EntityListItem'
import { SchemaButton } from './schema-button'
import { TableMenuEmptyState } from './TableMenuEmptyState'
const TableEditorMenu = () => {
const { id: _id } = useParams()
const id = _id ? Number(_id) : undefined
const snap = useTableEditorStateSnapshot()
const { selectedSchema, setSelectedSchema } = useQuerySchemaState()
const [showModal, setShowModal] = useState(false)
const [searchText, setSearchText] = useState<string>('')
const [visibleTypes, setVisibleTypes] = useState<string[]>(Object.values(ENTITY_TYPE))
const [sort, setSort] = useLocalStorage<'alphabetical' | 'grouped-alphabetical'>(
'table-editor-sort',
'alphabetical'
)
const { project } = useProjectContext()
const {
data,
isLoading,
isSuccess,
isError,
error,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
} = useEntityTypesQuery(
{
projectRef: project?.ref,
connectionString: project?.connectionString,
schemas: [selectedSchema],
search: searchText.trim() || undefined,
sort,
filterTypes: visibleTypes,
},
{
keepPreviousData: Boolean(searchText),
}
)
const entityTypes = useMemo(
() => data?.pages.flatMap((page) => page.data.entities),
[data?.pages]
)
const { data: schemas } = useSchemasQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const schema = schemas?.find((schema) => schema.name === selectedSchema)
const canCreateTables = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables')
const [protectedSchemas] = partition(
(schemas ?? []).sort((a, b) => a.name.localeCompare(b.name)),
(schema) => EXCLUDED_SCHEMAS.includes(schema?.name ?? '')
)
const isLocked = protectedSchemas.some((s) => s.id === schema?.id)
const { data: selectedTable } = useTableEditorQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
id,
})
useEffect(() => {
if (selectedTable?.schema) {
setSelectedSchema(selectedTable.schema)
}
}, [selectedTable?.schema])
return (
<>
<div className="h-full flex flex-col flex-grow gap-5 py-2">
<div className="px-4 flex justify-between items-center">
<h2 className="text-sm">Postgres</h2>
<div className="flex items-center gap-1">
<SchemaSelector
className="w-fit"
selectedSchemaName={selectedSchema}
onSelectSchema={(name: string) => {
setSearchText('')
setSelectedSchema(name)
}}
onSelectCreateSchema={() => snap.onAddSchema()}
/>
<Button
type="default"
icon={<Edit2 />}
className="w-[26px]"
onClick={snap.onAddTable}
aria-label="New table"
/>
</div>
</div>
{/* <div className="flex flex-col gap-y-1.5 "> */}
{/* <SchemaSelector
className="mx-4 w-fit"
selectedSchemaName={selectedSchema}
onSelectSchema={(name: string) => {
setSearchText('')
setSelectedSchema(name)
}}
onSelectCreateSchema={() => snap.onAddSchema()}
/> */}
{/* <div className="grid gap-3 mx-4"> */}
{/* {!isLocked ? (
<ButtonTooltip
block
title="Create a new table"
name="New table"
disabled={!canCreateTables}
size="tiny"
icon={<Plus size={14} strokeWidth={1.5} className="text-foreground-muted" />}
type="default"
className="justify-start"
onClick={snap.onAddTable}
tooltip={{
content: {
side: 'bottom',
text: !canCreateTables
? 'You need additional permissions to create tables'
: undefined,
},
}}
>
New table
</ButtonTooltip>
) : ( */}
{/* <Alert_Shadcn_>
<AlertTitle_Shadcn_ className="text-sm">Viewing protected schema</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_ className="text-xs">
<p className="mb-2">
This schema is managed by Supabase and is read-only through the table editor
</p>
<Button type="default" size="tiny" onClick={() => setShowModal(true)}>
Learn more
</Button>
</AlertDescription_Shadcn_>
</Alert_Shadcn_> */}
{/* )} */}
{/* </div> */}
{/* </div> */}
<div className="flex flex-auto flex-col gap-2">
<InnerSideBarFilters className="px-4">
<InnerSideBarFilterSearchInput
name="search-tables"
aria-labelledby="Search tables"
onChange={(e) => {
setSearchText(e.target.value)
}}
value={searchText}
placeholder="Search tables..."
>
<InnerSideBarFilterSortDropdown
value={sort}
onValueChange={(value: any) => setSort(value)}
>
<InnerSideBarFilterSortDropdownItem
key="alphabetical"
value="alphabetical"
className="flex gap-2"
>
Alphabetical
</InnerSideBarFilterSortDropdownItem>
<InnerSideBarFilterSortDropdownItem
key="grouped-alphabetical"
value="grouped-alphabetical"
>
Entity Type
</InnerSideBarFilterSortDropdownItem>
</InnerSideBarFilterSortDropdown>
</InnerSideBarFilterSearchInput>
<Popover_Shadcn_>
<PopoverTrigger_Shadcn_ asChild>
<Button
type={visibleTypes.length !== 5 ? 'default' : 'dashed'}
className="h-[28px] px-1.5"
icon={<Filter />}
/>
</PopoverTrigger_Shadcn_>
<PopoverContent_Shadcn_ className="p-0 w-56" side="bottom" align="center">
<div className="px-3 pt-3 pb-2 flex flex-col gap-y-2">
<p className="text-xs">Show entity types</p>
<div className="flex flex-col">
{Object.entries(ENTITY_TYPE).map(([key, value]) => (
<div key={key} className="group flex items-center justify-between py-0.5">
<div className="flex items-center gap-x-2">
<Checkbox_Shadcn_
id={key}
name={key}
checked={visibleTypes.includes(value)}
onCheckedChange={() => {
if (visibleTypes.includes(value)) {
setVisibleTypes(visibleTypes.filter((y) => y !== value))
} else {
setVisibleTypes(visibleTypes.concat([value]))
}
}}
/>
<Label_Shadcn_ htmlFor={key} className="capitalize text-xs">
{key.toLowerCase().replace('_', ' ')}
</Label_Shadcn_>
</div>
<Button
size="tiny"
type="default"
onClick={() => setVisibleTypes([value])}
className="transition opacity-0 group-hover:opacity-100 h-auto px-1 py-0.5"
>
Select only
</Button>
</div>
))}
</div>
</div>
</PopoverContent_Shadcn_>
</Popover_Shadcn_>
</InnerSideBarFilters>
{isLoading && (
<div className="flex flex-col gap-y-1">
<div className="flex flex-row h-6 px-4 items-center gap-2">
<Skeleton className="h-4 w-5" />
<Skeleton className="w-40 h-4" />
</div>
<div className="flex flex-row h-6 px-4 items-center gap-2">
<Skeleton className="h-4 w-5" />
<Skeleton className="w-32 h-4" />
</div>
<div className="flex flex-row h-6 px-4 items-center gap-2 opacity-75">
<Skeleton className="h-4 w-5" />
<Skeleton className="w-20 h-4" />
</div>
<div className="flex flex-row h-6 px-4 items-center gap-2 opacity-50">
<Skeleton className="h-4 w-5" />
<Skeleton className="w-40 h-4" />
</div>
<div className="flex flex-row h-6 px-4 items-center gap-2 opacity-25">
<Skeleton className="h-4 w-5" />
<Skeleton className="w-20 h-4" />
</div>
</div>
)}
{isError && (
<AlertError error={(error ?? null) as any} subject="Failed to retrieve tables" />
)}
{isSuccess && (
<>
{searchText.length === 0 && (entityTypes?.length ?? 0) <= 0 && (
<TableMenuEmptyState />
)}
{searchText.length > 0 && (entityTypes?.length ?? 0) <= 0 && (
<InnerSideBarEmptyPanel
className="mx-4"
title="No results found"
description={`Your search for "${searchText}" did not return any results`}
/>
)}
{(entityTypes?.length ?? 0) > 0 && (
<div className="flex flex-col flex-1" data-testid="tables-list">
<SchemaButton schema={schema} />
<InfiniteList
items={entityTypes}
ItemComponent={EntityListItem}
itemProps={{
projectRef: project?.ref!,
id: id ?? '',
isLocked,
}}
getItemSize={() => 28}
hasNextPage={hasNextPage}
isLoadingNextPage={isFetchingNextPage}
onLoadNextPage={() => fetchNextPage()}
/>
</div>
)}
</>
)}
</div>
</div>
<ProtectedSchemaModal visible={showModal} onClose={() => setShowModal(false)} />
</>
)
}
export default TableEditorMenu
@@ -1,78 +0,0 @@
import { Markdown } from 'components/interfaces/Markdown'
import { Sparkles } from 'lucide-react'
import { Button } from 'ui'
interface EmptyStatePanelProps {
title?: string
description?: string
buttonText?: string
onButtonClick?: () => void
aiButtonText?: string
onAiButtonClick?: () => void
showIcons?: boolean
}
export const EmptyStatePanel = ({
title = 'Active issues',
description = 'Active issues represent work that is currently in flight or should be worked on next. There are currently no active issues in this team.',
buttonText = 'Create new issue',
onButtonClick,
aiButtonText,
onAiButtonClick,
showIcons = true,
}: EmptyStatePanelProps) => {
return (
<div className="flex flex-col items-start gap-5 text-left w-full">
{showIcons && (
<div className="grid grid-cols-2 gap-1">
{[...Array(4)].map((_, i) => (
<div key={i} className="relative h-3 w-3">
<div className="absolute inset-0 rounded-full bg-gray-800" />
<div className="absolute inset-0.5 rounded-full bg-gray-700" />
{i === 1 && (
<div className="absolute inset-0.5 rounded-full bg-gray-700 overflow-hidden">
<div className="h-1/2 w-full bg-gray-800" />
</div>
)}
{i === 2 && (
<div className="absolute inset-0.5 rounded-full bg-gray-700 overflow-hidden">
<div className="h-1/2 w-1/2 bg-gray-800" />
</div>
)}
{i === 3 && (
<div className="absolute inset-0.5 rounded-full bg-gray-700 overflow-hidden">
<div className="h-3/4 w-3/4 rounded-full bg-gray-800 m-0.5" />
</div>
)}
</div>
))}
</div>
)}
<div className="space-y-2">
<h2 className="text-base text-foreground">{title}</h2>
<Markdown
content={description}
className="text-foreground-light text-sm whitespace-pre-line prose"
/>
</div>
{(buttonText || aiButtonText) && (
<div className="flex gap-3">
{buttonText && (
<Button type="default" onClick={onButtonClick}>
{buttonText}
</Button>
)}
{aiButtonText && (
<Button
type="primary"
icon={<Sparkles className="w-4 h-4" />}
onClick={onAiButtonClick}
>
{aiButtonText}
</Button>
)}
</div>
)}
</div>
)
}
@@ -1,90 +0,0 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { OngoingQueriesPanel } from 'components/interfaces/SQLEditor/OngoingQueriesPanel'
import NoPermission from 'components/ui/NoPermission'
import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions'
import { PanelLeftClose, PanelLeftOpen } from 'lucide-react'
import { ReactNode, useEffect, useMemo, useState } from 'react'
import { Button, cn, ResizableHandle, ResizablePanel, ResizablePanelGroup } from 'ui'
import { useSnapshot } from 'valtio'
import { ProjectLayoutWithAuth } from '../ProjectLayout/ProjectLayout'
import { ExplorerTabs } from '../tabs/explorer-tabs'
import { sidebarState } from '../tabs/sidebar-state'
import { SQLEditorMenu } from './SQLEditorMenu'
import TableEditorMenu from './TableEditorMenu'
import { CollapseButton } from '../tabs/collapse-button'
import { useParams } from 'common'
import { usePathname } from 'next/navigation'
export interface ExplorerLayoutProps {
children: ReactNode
hideTabs?: boolean
}
export const ExplorerLayout = ({ children }: ExplorerLayoutProps) => {
const { ref } = useParams()
const pathname = usePathname()
const [showOngoingQueries, setShowOngoingQueries] = useState(false)
const isPermissionsLoaded = usePermissionsLoaded()
const canReadTables = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'tables')
const productMenu = useMemo(
() => (
<ResizablePanelGroup direction="vertical" autoSaveId="explorer-side-panel">
<ResizablePanel defaultSize={40}>
<TableEditorMenu />
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize={40}>
<SQLEditorMenu
key="sql-editor-menu"
onViewOngoingQueries={() => setShowOngoingQueries(true)}
/>
</ResizablePanel>
<ResizableHandle />
<div className="p-4 sticky bottom-0 bg-studio">
<Button block type="default" onClick={() => setShowOngoingQueries(true)}>
View running queries
</Button>
</div>
</ResizablePanelGroup>
),
[]
)
if (isPermissionsLoaded && !canReadTables) {
return (
<ProjectLayoutWithAuth isBlocking={false}>
<NoPermission isFullPage resourceText="view tables from this project" />
</ProjectLayoutWithAuth>
)
}
const hideTabs = pathname === `/project/${ref}/explorer`
return (
<ProjectLayoutWithAuth
product="SQL Editor"
productMenu={productMenu}
isBlocking={false}
resizableSidebar
>
<div className="flex flex-col h-full">
<div
className={cn(
'h-10 flex items-center',
!hideTabs ? 'bg-surface-200 dark:bg-alternative' : 'bg-surface-100'
)}
>
{hideTabs && <CollapseButton hideTabs={hideTabs} />}
{!hideTabs && <ExplorerTabs />}
</div>
<div className="h-full">{children}</div>
</div>
<OngoingQueriesPanel
visible={showOngoingQueries}
onClose={() => setShowOngoingQueries(false)}
/>
</ProjectLayoutWithAuth>
)
}
@@ -1,27 +0,0 @@
import { useParams } from 'common'
import { Schema } from 'data/database/schemas-query'
import { Workflow } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { cn, TreeViewItemVariant } from 'ui'
export function SchemaButton({ schema }: { schema: Schema | undefined }) {
const { ref } = useParams()
const { asPath } = useRouter()
const active = asPath.includes(`/project/${ref}/explorer/schema/${schema?.name}`)
return (
<Link
href={`/project/${ref}/explorer/schema/${schema?.name}?schema=${schema?.name}`}
className={cn('px-4', TreeViewItemVariant({ isSelected: active }))}
>
{active && <div className="absolute left-0 h-full w-0.5 bg-foreground" />}
<Workflow
size={14}
className={cn(active ? 'text-foreground-light' : 'text-foreground-muted')}
/>
{schema?.name} visualizer
</Link>
)
}
@@ -3,9 +3,8 @@ import { horizontalListSortingStrategy, SortableContext, useSortable } from '@dn
import { CSS } from '@dnd-kit/utilities'
import { useParams } from 'common'
import { EntityTypeIcon } from 'components/tabs/entity-type-icon'
import { TabIcon } from 'components/tabs/TabIcon'
import { AnimatePresence, motion } from 'framer-motion'
import { Plus, X } from 'lucide-react'
import { CirclePlus, Plus, X } from 'lucide-react'
import { useRouter } from 'next/router'
import { useMemo } from 'react'
import {
@@ -97,7 +96,6 @@ const SortableTab = ({
)}
{...listeners}
>
{/* <TabIcon type={tab.type} /> */}
<EntityTypeIcon type={tab.type} />
<div className="flex items-center gap-0">
<AnimatePresence mode="popLayout" initial>
@@ -224,7 +222,7 @@ export function ExplorerTabs({ onClose }: TabsProps) {
'hover:bg-surface-300 dark:hover:bg-surface-100'
)}
>
<TabIcon type={'new'} />
<Plus size={16} strokeWidth={1.5} className={'text-foreground-lighter'} />
<div className="flex items-center gap-0">
<span>New</span>
</div>
@@ -40,15 +40,6 @@ export function NewTab() {
isBeta: false,
onClick: handleNewQuery,
},
{
icon: <Upload className="h-4 w-4 text-foreground" strokeWidth={1.5} />,
title: 'Upload CSV',
description: 'Import data from CSV files',
bgColor: 'bg-green-500',
isBeta: false,
onClick: handleNewQuery,
},
]
const sqlEditorActions = [
@@ -60,28 +51,6 @@ export function NewTab() {
isBeta: false,
onClick: handleNewQuery,
},
{
icon: (
<div className="relative">
<SQL_ICON className={cn('fill-foreground', 'w-4 h-4')} strokeWidth={1.5} />
<div className="absolute -right-3 -top-4 bg-surface-300 border w-5 h-5 flex items-center justify-center rounded-full">
<BarChart2 className="h-3 w-3 text-foreground-light" strokeWidth={3} />
</div>
</div>
),
title: 'New SQL Snippet Chart',
description: 'Create data visualizations',
bgColor: 'bg-green-500',
onClick: handleNewQuery,
// isBeta: true,
},
// {
// icon: <BarChart2 className="h-4 w-4 text-foreground" strokeWidth={2} />,
// title: 'New Report',
// description: 'Create data visualizations',
// bgColor: 'bg-orange-500',
// isBeta: true,
// },
]
const actions = editor === 'sql' ? sqlEditorActions : tableEditorActions
@@ -89,7 +58,7 @@ export function NewTab() {
return (
<div className="bg-surface-100 h-full overflow-y-auto py-12">
<div className="mx-auto max-w-2xl flex flex-col gap-10">
<div className="flex flex-wrap gap-5">
<div className="grid grid-cols-2 gap-4">
{actions.map((item, i) => (
<ActionCard key={`action-card-${i}`} {...item} />
))}
-61
View File
@@ -1,61 +0,0 @@
import { CirclePlus, Eye, Table2, Workflow } from 'lucide-react'
import { TabType } from 'state/tabs'
import { cn, SQL_ICON } from 'ui'
interface TabIconProps {
size?: number
strokeWidth?: number
className?: string
type: TabType
}
export const TabIcon = ({ type, size = 14, strokeWidth = 1.5, className }: TabIconProps) => {
switch (type) {
case 'schema':
return (
<Workflow
size={size}
strokeWidth={strokeWidth}
className={className ?? 'text-foreground-lighter'}
/>
)
case 'table':
return (
<Table2
size={size}
strokeWidth={strokeWidth}
className={className ?? 'text-foreground-lighter'}
/>
)
case 'view':
return (
<Eye
size={size}
strokeWidth={strokeWidth}
className={className ?? 'text-foreground-lighter'}
/>
)
case 'sql':
return (
<SQL_ICON
className={cn(
'transition-colors',
'fill-foreground-muted',
'group-aria-selected:fill-foreground',
className,
'w-4 h-4',
'-ml-0.5'
)}
strokeWidth={strokeWidth}
/>
)
default:
return (
<CirclePlus
size={size}
strokeWidth={strokeWidth}
className={className ?? 'text-foreground-lighter'}
/>
)
}
}