mirror of
https://github.com/supabase/supabase.git
synced 2026-10-17 07:09:07 +03:00
remove explorer files
This commit is contained in:
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 = () => {
|
||||
|
||||
File renamed without changes.
@@ -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} />
|
||||
))}
|
||||
|
||||
@@ -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'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user