feat: new item style for table editor and empty states for both editors (#33357)
* feat: new item style for table editor and empty states for both editors * flex shrink issue * Update SqlEditorMenuStaticLinks.tsx * Fix the test. --------- Co-authored-by: Ivan Vasilov <vasilov.ivan@gmail.com>
This commit is contained in:
committed by
GitHub
parent
c8413fa022
commit
958cebbb2b
@@ -30,10 +30,6 @@ import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
import { useAppStateSnapshot } from 'state/app-state'
|
||||
import { useParams } from 'common'
|
||||
|
||||
interface OngoingQueriesPanel {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export const OngoingQueriesPanel = () => {
|
||||
const [_, setParams] = useUrlState({ replace: true })
|
||||
const { viewOngoingQueries } = useParams()
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Pointer } from 'lucide-react'
|
||||
import { TreeViewItem } from 'ui'
|
||||
import { InnerSideBarEmptyPanel } from 'ui-patterns'
|
||||
|
||||
export const EmptyPrivateQueriesPanel = () => (
|
||||
<InnerSideBarEmptyPanel
|
||||
title="No private queries created yet"
|
||||
description="Queries will be automatically saved once you start writing in the editor"
|
||||
className="mx-4"
|
||||
>
|
||||
<div className="top-0 left-6 flex flex-col opacity-50 cursor-not-allowed bg-dash-sidebar h-content -mb-7 pointer-events-none scale-75">
|
||||
<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-[128px] 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" key={`dummy-${i + 1}`}>
|
||||
<TreeViewItem
|
||||
isSelected={i === 2}
|
||||
id={`dummy-${i + 1}`}
|
||||
name={`dummy_query_${i + 1}`}
|
||||
level={1}
|
||||
xPadding={16}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</InnerSideBarEmptyPanel>
|
||||
)
|
||||
@@ -1,10 +1,8 @@
|
||||
import { ReactNode, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { OngoingQueriesPanel } from 'components/interfaces/SQLEditor/OngoingQueriesPanel'
|
||||
import { withAuth } from 'hooks/misc/withAuth'
|
||||
import { ReactNode, useMemo } from 'react'
|
||||
import ProjectLayout from '../ProjectLayout/ProjectLayout'
|
||||
import { SQLEditorMenu } from './SQLEditorMenu'
|
||||
import { useParams } from 'common'
|
||||
|
||||
export interface SQLEditorLayoutProps {
|
||||
title: string
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { FilePlus, FolderPlus, Plus, X } from 'lucide-react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { useDebounce } from '@uidotdev/usehooks'
|
||||
import { useParams } from 'common'
|
||||
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
@@ -11,6 +6,11 @@ import { useLocalStorage } from 'hooks/misc/useLocalStorage'
|
||||
import { useSelectedProject } from 'hooks/misc/useSelectedProject'
|
||||
import { LOCAL_STORAGE_KEYS } from 'lib/constants'
|
||||
import { useProfile } from 'lib/profile'
|
||||
import { FilePlus, FolderPlus, Plus, X } from 'lucide-react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { getAppStateSnapshot } from 'state/app-state'
|
||||
import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2'
|
||||
import {
|
||||
Button,
|
||||
@@ -27,11 +27,10 @@ import {
|
||||
InnerSideBarFilterSearchInput,
|
||||
InnerSideBarFilterSortDropdown,
|
||||
InnerSideBarFilterSortDropdownItem,
|
||||
InnerSideMenuItem,
|
||||
} from 'ui-patterns/InnerSideMenu'
|
||||
import { SQLEditorNav } from './SQLEditorNavV2/SQLEditorNav'
|
||||
import { SqlEditorMenuStaticLinks } from './SqlEditorMenuStaticLinks'
|
||||
import { SearchList } from './SQLEditorNavV2/SearchList'
|
||||
import { getAppStateSnapshot } from 'state/app-state'
|
||||
import { SQLEditorNav } from './SQLEditorNavV2/SQLEditorNav'
|
||||
|
||||
export const SQLEditorMenu = () => {
|
||||
const router = useRouter()
|
||||
@@ -157,23 +156,7 @@ export const SQLEditorMenu = () => {
|
||||
<SearchList search={debouncedSearch} />
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
|
||||
<SqlEditorMenuStaticLinks />
|
||||
<SQLEditorNav sort={sort} />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -9,6 +9,7 @@ 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 EditorMenuListSkeleton from 'components/layouts/TableEditorLayout/EditorMenuListSkeleton'
|
||||
import { useContentCountQuery } from 'data/content/content-count-query'
|
||||
import { useContentDeleteMutation } from 'data/content/content-delete-mutation'
|
||||
import { getContentById } from 'data/content/content-id-query'
|
||||
@@ -36,6 +37,7 @@ import {
|
||||
InnerSideMenuSeparator,
|
||||
} from 'ui-patterns'
|
||||
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
import { EmptyPrivateQueriesPanel } from 'components/layouts/SQLEditorLayout/PrivateSqlSnippetEmpty'
|
||||
import SQLEditorLoadingSnippets from './SQLEditorLoadingSnippets'
|
||||
import { formatFolderResponseForTreeView, getLastItemIds, ROOT_NODE } from './SQLEditorNav.utils'
|
||||
import { SQLEditorTreeViewItem } from './SQLEditorTreeViewItem'
|
||||
@@ -53,6 +55,7 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
|
||||
const project = useSelectedProject()
|
||||
const { ref: projectRef, id } = useParams()
|
||||
const snapV2 = useSqlEditorV2StateSnapshot()
|
||||
// const tabStore = getTabsStore(projectRef)
|
||||
const [sectionVisibility, setSectionVisibility] = useLocalStorage<SectionState>(
|
||||
LOCAL_STORAGE_KEYS.SQL_EDITOR_SECTION_STATE(projectRef ?? ''),
|
||||
DEFAULT_SECTION_STATE
|
||||
@@ -487,14 +490,14 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
|
||||
useEffect(() => {
|
||||
if (projectRef && privateSnippetsPages) {
|
||||
privateSnippetsPages.pages.forEach((page) => {
|
||||
page.contents?.forEach((snippet) => {
|
||||
page.contents?.forEach((snippet: Snippet) => {
|
||||
snapV2.addSnippet({
|
||||
projectRef,
|
||||
snippet,
|
||||
})
|
||||
})
|
||||
|
||||
page.folders?.forEach((folder) => snapV2.addFolder({ projectRef, folder }))
|
||||
page.folders?.forEach((folder: SnippetFolder) => snapV2.addFolder({ projectRef, folder }))
|
||||
})
|
||||
}
|
||||
}, [projectRef, privateSnippetsPages?.pages])
|
||||
@@ -527,58 +530,58 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<InnerSideMenuSeparator />
|
||||
|
||||
<InnerSideMenuCollapsible
|
||||
className="px-0"
|
||||
open={showSharedSnippets}
|
||||
onOpenChange={(value) => {
|
||||
setSectionVisibility({ ...(sectionVisibility ?? DEFAULT_SECTION_STATE), shared: value })
|
||||
}}
|
||||
className="px-0"
|
||||
>
|
||||
<InnerSideMenuCollapsibleTrigger
|
||||
title={`Shared ${numProjectSnippets > 0 ? ` (${numProjectSnippets})` : ''}`}
|
||||
/>
|
||||
<InnerSideMenuCollapsibleContent className="group-data-[state=open]:pt-2">
|
||||
{isLoadingSharedSqlSnippets ? (
|
||||
<SQLEditorLoadingSnippets />
|
||||
) : numProjectSnippets === 0 ? (
|
||||
{numProjectSnippets === 0 ? (
|
||||
<InnerSideBarEmptyPanel
|
||||
className="mx-2 px-3"
|
||||
className="mx-2"
|
||||
title="No shared queries"
|
||||
description="Share queries with your team by right-clicking on the query"
|
||||
description="Share queries with your team by right-clicking on the query."
|
||||
/>
|
||||
) : (
|
||||
<TreeView
|
||||
data={projectSnippetsTreeState}
|
||||
aria-label="project-level-snippets"
|
||||
nodeRenderer={({ element, ...props }) => (
|
||||
<SQLEditorTreeViewItem
|
||||
{...props}
|
||||
element={element}
|
||||
onSelectDelete={() => {
|
||||
setShowDeleteModal(true)
|
||||
setSelectedSnippets([element.metadata as unknown as Snippet])
|
||||
}}
|
||||
onSelectRename={() => {
|
||||
setShowRenameModal(true)
|
||||
setSelectedSnippetToRename(element.metadata as Snippet)
|
||||
}}
|
||||
onSelectDownload={() => {
|
||||
setSelectedSnippetToDownload(element.metadata as Snippet)
|
||||
}}
|
||||
onSelectDuplicate={() => {
|
||||
onSelectDuplicate(element.metadata as Snippet)
|
||||
}}
|
||||
onSelectUnshare={() => {
|
||||
setSelectedSnippetToUnshare(element.metadata as Snippet)
|
||||
}}
|
||||
isLastItem={projectSnippetsLastItemIds.has(element.id as string)}
|
||||
hasNextPage={hasMoreSharedSqlSnippets}
|
||||
fetchNextPage={fetchNextSharedSqlSnippets}
|
||||
isFetchingNextPage={isFetchingMoreSharedSqlSnippets}
|
||||
/>
|
||||
)}
|
||||
nodeRenderer={({ element, ...props }) => {
|
||||
const isActive = element.metadata?.id === id
|
||||
return (
|
||||
<SQLEditorTreeViewItem
|
||||
{...props}
|
||||
isSelected={isActive}
|
||||
element={element}
|
||||
onSelectDelete={() => {
|
||||
setShowDeleteModal(true)
|
||||
setSelectedSnippets([element.metadata as unknown as Snippet])
|
||||
}}
|
||||
onSelectRename={() => {
|
||||
setShowRenameModal(true)
|
||||
setSelectedSnippetToRename(element.metadata as Snippet)
|
||||
}}
|
||||
onSelectDownload={() => {
|
||||
setSelectedSnippetToDownload(element.metadata as Snippet)
|
||||
}}
|
||||
onSelectDuplicate={() => {
|
||||
onSelectDuplicate(element.metadata as Snippet)
|
||||
}}
|
||||
onSelectUnshare={() => {
|
||||
setSelectedSnippetToUnshare(element.metadata as Snippet)
|
||||
}}
|
||||
isLastItem={projectSnippetsLastItemIds.has(element.id as string)}
|
||||
hasNextPage={hasMoreSharedSqlSnippets}
|
||||
fetchNextPage={fetchNextSharedSqlSnippets}
|
||||
isFetchingNextPage={isFetchingMoreSharedSqlSnippets}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</InnerSideMenuCollapsibleContent>
|
||||
@@ -615,34 +618,38 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
|
||||
<TreeView
|
||||
data={favoritesTreeState}
|
||||
aria-label="favorite-snippets"
|
||||
nodeRenderer={({ element, ...props }) => (
|
||||
<SQLEditorTreeViewItem
|
||||
{...props}
|
||||
element={element}
|
||||
onSelectDelete={() => {
|
||||
setShowDeleteModal(true)
|
||||
setSelectedSnippets([element.metadata as unknown as Snippet])
|
||||
}}
|
||||
onSelectRename={() => {
|
||||
setShowRenameModal(true)
|
||||
setSelectedSnippetToRename(element.metadata as Snippet)
|
||||
}}
|
||||
onSelectDownload={() => {
|
||||
setSelectedSnippetToDownload(element.metadata as Snippet)
|
||||
}}
|
||||
onSelectDuplicate={() => {
|
||||
onSelectDuplicate(element.metadata as Snippet)
|
||||
}}
|
||||
onSelectShare={() => setSelectedSnippetToShare(element.metadata as Snippet)}
|
||||
onSelectUnshare={() => {
|
||||
setSelectedSnippetToUnshare(element.metadata as Snippet)
|
||||
}}
|
||||
isLastItem={favoriteSnippetsLastItemIds.has(element.id as string)}
|
||||
hasNextPage={hasMoreFavoriteSqlSnippets}
|
||||
fetchNextPage={fetchNextFavoriteSqlSnippets}
|
||||
isFetchingNextPage={isFetchingMoreFavoriteSqlSnippets}
|
||||
/>
|
||||
)}
|
||||
nodeRenderer={({ element, ...props }) => {
|
||||
const isActive = element.metadata?.id === id
|
||||
return (
|
||||
<SQLEditorTreeViewItem
|
||||
{...props}
|
||||
isSelected={isActive}
|
||||
element={element}
|
||||
onSelectDelete={() => {
|
||||
setShowDeleteModal(true)
|
||||
setSelectedSnippets([element.metadata as unknown as Snippet])
|
||||
}}
|
||||
onSelectRename={() => {
|
||||
setShowRenameModal(true)
|
||||
setSelectedSnippetToRename(element.metadata as Snippet)
|
||||
}}
|
||||
onSelectDownload={() => {
|
||||
setSelectedSnippetToDownload(element.metadata as Snippet)
|
||||
}}
|
||||
onSelectDuplicate={() => {
|
||||
onSelectDuplicate(element.metadata as Snippet)
|
||||
}}
|
||||
onSelectShare={() => setSelectedSnippetToShare(element.metadata as Snippet)}
|
||||
onSelectUnshare={() => {
|
||||
setSelectedSnippetToUnshare(element.metadata as Snippet)
|
||||
}}
|
||||
isLastItem={favoriteSnippetsLastItemIds.has(element.id as string)}
|
||||
hasNextPage={hasMoreFavoriteSqlSnippets}
|
||||
fetchNextPage={fetchNextFavoriteSqlSnippets}
|
||||
isFetchingNextPage={isFetchingMoreFavoriteSqlSnippets}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</InnerSideMenuCollapsibleContent>
|
||||
@@ -663,13 +670,9 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
|
||||
/>
|
||||
<InnerSideMenuCollapsibleContent className="group-data-[state=open]:pt-2">
|
||||
{isLoading ? (
|
||||
<SQLEditorLoadingSnippets />
|
||||
<EditorMenuListSkeleton />
|
||||
) : folders.length === 0 && numPrivateSnippets === 0 ? (
|
||||
<InnerSideBarEmptyPanel
|
||||
className="mx-3 px-4"
|
||||
title="No queries created yet"
|
||||
description="Queries will be automatically saved once you start writing in the editor"
|
||||
/>
|
||||
<EmptyPrivateQueriesPanel />
|
||||
) : (
|
||||
<TreeView
|
||||
multiSelect
|
||||
@@ -688,75 +691,82 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
|
||||
}
|
||||
}}
|
||||
expandedIds={expandedFolderIds}
|
||||
nodeRenderer={({ element, ...props }) => (
|
||||
<SQLEditorTreeViewItem
|
||||
{...props}
|
||||
element={element}
|
||||
isMultiSelected={selectedSnippets.length > 1}
|
||||
isLastItem={privateSnippetsLastItemIds.has(element.id as string)}
|
||||
status={props.isBranch ? snapV2.folders[element.id].status : 'idle'}
|
||||
onMultiSelect={onMultiSelect}
|
||||
onSelectCreate={() => {
|
||||
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])
|
||||
nodeRenderer={({ element, ...props }) => {
|
||||
const isActive = element.metadata?.id === id
|
||||
|
||||
return (
|
||||
<SQLEditorTreeViewItem
|
||||
{...props}
|
||||
element={element}
|
||||
isSelected={isActive}
|
||||
isMultiSelected={selectedSnippets.length > 1}
|
||||
isLastItem={privateSnippetsLastItemIds.has(element.id as string)}
|
||||
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)
|
||||
}
|
||||
}}
|
||||
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)}
|
||||
onSelectDuplicate={() => onSelectDuplicate(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 })
|
||||
}
|
||||
}}
|
||||
hasNextPage={hasNextPage}
|
||||
fetchNextPage={fetchNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
sort={sort}
|
||||
onFolderContentsChange={({ isLoading, snippets }) => {
|
||||
setSubResults((prev) => ({
|
||||
...prev,
|
||||
[element.id as string]: { snippets, isLoading },
|
||||
}))
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
onSelectDuplicate={() => onSelectDuplicate(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 })
|
||||
}
|
||||
}}
|
||||
hasNextPage={hasNextPage}
|
||||
fetchNextPage={fetchNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
sort={sort}
|
||||
onFolderContentsChange={({ isLoading, snippets }) => {
|
||||
setSubResults((prev) => ({
|
||||
...prev,
|
||||
[element.id as string]: { snippets, isLoading },
|
||||
}))
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</InnerSideMenuCollapsibleContent>
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
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 { useEffect } from 'react'
|
||||
|
||||
import { IS_PLATFORM } from 'common'
|
||||
import { useParams } from 'common/hooks/useParams'
|
||||
import { useSQLSnippetFolderContentsQuery } from 'data/content/sql-folder-contents-query'
|
||||
@@ -11,6 +6,10 @@ import { Snippet } from 'data/content/sql-folders-query'
|
||||
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import useLatest from 'hooks/misc/useLatest'
|
||||
import { useProfile } from 'lib/profile'
|
||||
import { Copy, Download, Edit, ExternalLink, Lock, Move, Plus, Share, Trash } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { ComponentProps, useEffect } from 'react'
|
||||
import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2'
|
||||
import {
|
||||
Button,
|
||||
@@ -22,12 +21,9 @@ import {
|
||||
TreeViewItem,
|
||||
} from 'ui'
|
||||
|
||||
interface SQLEditorTreeViewItemProps {
|
||||
interface SQLEditorTreeViewItemProps
|
||||
extends Omit<ComponentProps<typeof TreeViewItem>, 'name' | 'xPadding'> {
|
||||
element: any
|
||||
level: number
|
||||
isBranch: boolean
|
||||
isSelected: boolean
|
||||
isExpanded: boolean
|
||||
isMultiSelected?: boolean
|
||||
status?: 'editing' | 'saving' | 'idle'
|
||||
getNodeProps: () => any
|
||||
@@ -79,11 +75,12 @@ export const SQLEditorTreeViewItem = ({
|
||||
sort,
|
||||
name,
|
||||
onFolderContentsChange,
|
||||
...props
|
||||
}: SQLEditorTreeViewItemProps) => {
|
||||
const router = useRouter()
|
||||
const { id, ref: projectRef } = useParams()
|
||||
const { profile } = useProfile()
|
||||
const { className, onClick } = getNodeProps()
|
||||
|
||||
const snapV2 = useSqlEditorV2StateSnapshot()
|
||||
|
||||
const isOwner = profile?.id === element?.metadata.owner_id
|
||||
@@ -163,12 +160,9 @@ export const SQLEditorTreeViewItem = ({
|
||||
<ContextMenuTrigger_Shadcn_ asChild>
|
||||
<TreeViewItem
|
||||
level={level}
|
||||
xPadding={16}
|
||||
name={element.name}
|
||||
className={className}
|
||||
isExpanded={isExpanded}
|
||||
isBranch={isBranch}
|
||||
isSelected={isSelected || id === element.id}
|
||||
isSelected={isSelected}
|
||||
isEditing={isEditing}
|
||||
isLoading={(isEnabled && isLoading) || isSaving}
|
||||
onEditSubmit={(value) => {
|
||||
@@ -189,10 +183,11 @@ export const SQLEditorTreeViewItem = ({
|
||||
if (isEditing) {
|
||||
return
|
||||
}
|
||||
|
||||
onClick(e)
|
||||
}
|
||||
}}
|
||||
{...props}
|
||||
name={element.name}
|
||||
xPadding={16}
|
||||
/>
|
||||
</ContextMenuTrigger_Shadcn_>
|
||||
<ContextMenuContent_Shadcn_ onCloseAutoFocus={(e) => e.stopPropagation()}>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useParams } from 'common'
|
||||
import { useRouter } from 'next/router'
|
||||
import { InnerSideMenuDataItem } from 'ui-patterns/InnerSideMenu'
|
||||
|
||||
const OPTIONS = ['templates', 'quickstarts'] as const
|
||||
|
||||
export function SqlEditorMenuStaticLinks() {
|
||||
const { ref } = useParams()
|
||||
const router = useRouter()
|
||||
|
||||
function isPageActive(key: string): boolean {
|
||||
return router.asPath === `/project/${ref}/sql/${key}`
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{OPTIONS.map((pageId) => {
|
||||
const active = isPageActive(pageId)
|
||||
return (
|
||||
<InnerSideMenuDataItem
|
||||
title="Templates"
|
||||
isActive={active}
|
||||
isOpened={false}
|
||||
href={`/project/${ref}/sql/${pageId}`}
|
||||
className="capitalize"
|
||||
>
|
||||
{pageId}
|
||||
</InnerSideMenuDataItem>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { memo } from 'react'
|
||||
import { Skeleton } from 'ui'
|
||||
|
||||
const EditorMenuListSkeleton = memo(function EditorMenuListSkeleton() {
|
||||
const items = [
|
||||
{ width: 'w-40', opacity: 'opacity-100' },
|
||||
{ width: 'w-32', opacity: 'opacity-100' },
|
||||
{ width: 'w-20', opacity: 'opacity-75' },
|
||||
{ width: 'w-40', opacity: 'opacity-50' },
|
||||
{ width: 'w-20', opacity: 'opacity-25' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="px-4 flex flex-col gap-0">
|
||||
{items.map((item, index) => (
|
||||
<div key={index} className={`flex flex-row h-6 items-center gap-3 ${item.opacity}`}>
|
||||
<Skeleton className="h-4 w-5" />
|
||||
<Skeleton className={`h-4 ${item.width}`} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export default EditorMenuListSkeleton
|
||||
@@ -1,20 +1,3 @@
|
||||
import saveAs from 'file-saver'
|
||||
import {
|
||||
Clipboard,
|
||||
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,
|
||||
@@ -25,6 +8,7 @@ import {
|
||||
formatTableRowsToSQL,
|
||||
getEntityLintDetails,
|
||||
} from 'components/interfaces/TableGridEditor/TableEntity.utils'
|
||||
import { EntityTypeIcon } from 'components/ui/EntityTypeIcon'
|
||||
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'
|
||||
@@ -33,8 +17,14 @@ 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 saveAs from 'file-saver'
|
||||
import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState'
|
||||
import { LOCAL_STORAGE_KEYS } from 'lib/constants'
|
||||
import { copyToClipboard } from 'lib/helpers'
|
||||
import { Clipboard, Copy, Download, Edit, Lock, MoreHorizontal, Trash, Unlock } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import Papa from 'papaparse'
|
||||
import { toast } from 'sonner'
|
||||
import { useTableEditorStateSnapshot } from 'state/table-editor'
|
||||
import {
|
||||
cn,
|
||||
@@ -49,13 +39,16 @@ import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
TreeViewItemVariant,
|
||||
} from 'ui'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { useProjectContext } from '../ProjectLayout/ProjectContext'
|
||||
|
||||
export interface EntityListItemProps {
|
||||
id: number
|
||||
id: number | string
|
||||
projectRef: string
|
||||
isLocked: boolean
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
const EntityListItem: ItemRenderer<Entity, EntityListItemProps> = ({
|
||||
@@ -63,6 +56,7 @@ const EntityListItem: ItemRenderer<Entity, EntityListItemProps> = ({
|
||||
projectRef,
|
||||
item: entity,
|
||||
isLocked,
|
||||
isActive: _isActive,
|
||||
}) => {
|
||||
const { project } = useProjectContext()
|
||||
const snap = useTableEditorStateSnapshot()
|
||||
@@ -212,106 +206,25 @@ const EntityListItem: ItemRenderer<Entity, EntityListItemProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
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 disableHoverableContent={true}>
|
||||
<TooltipTrigger className="min-w-4" asChild>
|
||||
<Unlock
|
||||
size={14}
|
||||
strokeWidth={2}
|
||||
className={cn('min-w-4', isActive ? 'text-warning-600' : 'text-warning-500')}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{tooltipContent}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-2">
|
||||
<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(
|
||||
'w-full',
|
||||
'flex items-center gap-2',
|
||||
'py-1 px-2',
|
||||
'text-light',
|
||||
'rounded-md',
|
||||
isActive ? 'bg-selection' : 'hover:bg-surface-200 focus:bg-surface-200',
|
||||
'group',
|
||||
'transition'
|
||||
)}
|
||||
>
|
||||
<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,
|
||||
}),
|
||||
'px-4'
|
||||
)}
|
||||
>
|
||||
<>
|
||||
{isActive && <div className="absolute left-0 h-full w-0.5 bg-foreground" />}
|
||||
<Tooltip disableHoverableContent={true}>
|
||||
<TooltipTrigger 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>
|
||||
)}
|
||||
<TooltipTrigger className="min-w-4">
|
||||
<EntityTypeIcon type={entity.type} isActive={isActive} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{formatTooltipText(entity.type)}</TooltipContent>
|
||||
</Tooltip>
|
||||
@@ -332,12 +245,18 @@ const EntityListItem: ItemRenderer<Entity, EntityListItemProps> = ({
|
||||
>
|
||||
{entity.name}
|
||||
</span>
|
||||
<EntityTooltipTrigger entity={entity} />
|
||||
<EntityTooltipTrigger
|
||||
entity={entity}
|
||||
isActive={isActive}
|
||||
tableHasLints={tableHasLints}
|
||||
viewHasLints={viewHasLints}
|
||||
materializedViewHasLints={materializedViewHasLints}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{canEdit && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="text-foreground-lighter transition-all hover:text-foreground data-[state=open]:text-foreground">
|
||||
<DropdownMenuTrigger className="text-foreground-lighter transition-all text-transparent group-hover:text-foreground data-[state=open]:text-foreground">
|
||||
<MoreHorizontal size={14} strokeWidth={2} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="start" className="w-44">
|
||||
@@ -435,9 +354,67 @@ const EntityListItem: ItemRenderer<Entity, EntityListItemProps> = ({
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</EditorTablePageLink>
|
||||
</div>
|
||||
</>
|
||||
</EditorTablePageLink>
|
||||
)
|
||||
}
|
||||
|
||||
const EntityTooltipTrigger = ({
|
||||
entity,
|
||||
isActive,
|
||||
tableHasLints,
|
||||
viewHasLints,
|
||||
materializedViewHasLints,
|
||||
}: {
|
||||
entity: Entity
|
||||
isActive: boolean
|
||||
tableHasLints: boolean
|
||||
viewHasLints: boolean
|
||||
materializedViewHasLints: boolean
|
||||
}) => {
|
||||
let tooltipContent = ''
|
||||
|
||||
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 disableHoverableContent={true}>
|
||||
<TooltipTrigger className="min-w-4">
|
||||
<Unlock
|
||||
size={14}
|
||||
strokeWidth={2}
|
||||
className={cn('min-w-4', isActive ? 'text-warning-600' : 'text-warning-500')}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<span>{tooltipContent}</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export default EntityListItem
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { partition } from 'lodash'
|
||||
import { Filter, Plus } from 'lucide-react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { useBreakpoint, useParams } from 'common'
|
||||
import { useParams } from 'common'
|
||||
import { useBreakpoint } from 'common/hooks/useBreakpoint'
|
||||
import { ProtectedSchemaModal } from 'components/interfaces/Database/ProtectedSchemaWarning'
|
||||
import EditorMenuListSkeleton from 'components/layouts/TableEditorLayout/EditorMenuListSkeleton'
|
||||
import AlertError from 'components/ui/AlertError'
|
||||
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
|
||||
import InfiniteList from 'components/ui/InfiniteList'
|
||||
@@ -18,6 +15,10 @@ import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useLocalStorage } from 'hooks/misc/useLocalStorage'
|
||||
import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState'
|
||||
import { PROTECTED_SCHEMAS } from 'lib/constants/schemas'
|
||||
import { partition } from 'lodash'
|
||||
import { Filter, Plus } from 'lucide-react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTableEditorStateSnapshot } from 'state/table-editor'
|
||||
import {
|
||||
AlertDescription_Shadcn_,
|
||||
@@ -36,10 +37,10 @@ import {
|
||||
InnerSideBarFilterSortDropdown,
|
||||
InnerSideBarFilterSortDropdownItem,
|
||||
InnerSideBarFilters,
|
||||
InnerSideBarShimmeringLoaders,
|
||||
} from 'ui-patterns/InnerSideMenu'
|
||||
import { useProjectContext } from '../ProjectLayout/ProjectContext'
|
||||
import EntityListItem from './EntityListItem'
|
||||
import { TableMenuEmptyState } from './TableMenuEmptyState'
|
||||
|
||||
const TableEditorMenu = () => {
|
||||
const { id: _id } = useParams()
|
||||
@@ -114,10 +115,7 @@ const TableEditorMenu = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="pt-5 flex flex-col flex-grow gap-5 h-full"
|
||||
style={{ maxHeight: 'calc(100vh - 48px)' }}
|
||||
>
|
||||
<div className="flex flex-col flex-grow gap-5 pt-5 h-full">
|
||||
<div className="flex flex-col gap-y-1.5">
|
||||
<SchemaSelector
|
||||
className="mx-4"
|
||||
@@ -170,8 +168,8 @@ const TableEditorMenu = () => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-auto flex-col gap-2 pb-4 px-2">
|
||||
<InnerSideBarFilters>
|
||||
<div className="flex flex-auto flex-col gap-2 pb-4">
|
||||
<InnerSideBarFilters className="mx-2">
|
||||
<InnerSideBarFilterSearchInput
|
||||
autoFocus={!isMobile}
|
||||
name="search-tables"
|
||||
@@ -248,20 +246,18 @@ const TableEditorMenu = () => {
|
||||
</Popover_Shadcn_>
|
||||
</InnerSideBarFilters>
|
||||
|
||||
{isLoading && <InnerSideBarShimmeringLoaders />}
|
||||
{isLoading && <EditorMenuListSkeleton />}
|
||||
|
||||
{isError && (
|
||||
<AlertError error={(error ?? null) as any} subject="Failed to retrieve tables" />
|
||||
<div className="mx-4">
|
||||
<AlertError error={(error ?? null) as any} subject="Failed to retrieve tables" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSuccess && (
|
||||
<>
|
||||
{searchText.length === 0 && (entityTypes?.length ?? 0) <= 0 && (
|
||||
<InnerSideBarEmptyPanel
|
||||
className="mx-2"
|
||||
title="No entities available"
|
||||
description="This schema has no entities available yet"
|
||||
/>
|
||||
<TableMenuEmptyState />
|
||||
)}
|
||||
{searchText.length > 0 && (entityTypes?.length ?? 0) <= 0 && (
|
||||
<InnerSideBarEmptyPanel
|
||||
@@ -271,9 +267,10 @@ const TableEditorMenu = () => {
|
||||
/>
|
||||
)}
|
||||
{(entityTypes?.length ?? 0) > 0 && (
|
||||
<div className="flex flex-1 -mx-2" data-testid="tables-list">
|
||||
<div className="flex flex-1 flex-grow" data-testid="tables-list">
|
||||
<InfiniteList
|
||||
items={entityTypes}
|
||||
// @ts-expect-error
|
||||
ItemComponent={EntityListItem}
|
||||
itemProps={{
|
||||
projectRef: project?.ref!,
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useParams } from 'common'
|
||||
import { Pointer } from 'lucide-react'
|
||||
import { useRef } from 'react'
|
||||
import { InnerSideBarEmptyPanel } from 'ui-patterns/InnerSideMenu'
|
||||
import { cn, TreeViewItemVariant } from 'ui'
|
||||
import { EntityTypeIcon } from 'components/ui/EntityTypeIcon'
|
||||
|
||||
export const TableMenuEmptyState = () => {
|
||||
return (
|
||||
<InnerSideBarEmptyPanel
|
||||
title="No tables or views"
|
||||
description="Any tables or views you create will be listed here."
|
||||
className="mx-4"
|
||||
>
|
||||
<div className="top-0 left-6 flex flex-col opacity-50 cursor-not-allowed bg-dash-sidebar h-content -mb-7 pointer-events-none scale-75">
|
||||
<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" key={`some-${i}`}>
|
||||
<div
|
||||
className={cn(
|
||||
'group',
|
||||
TreeViewItemVariant({
|
||||
isSelected: i === 2 ? true : false,
|
||||
isOpened: i === 2 ? true : false,
|
||||
isPreview: false,
|
||||
}),
|
||||
'px-4 min-w-40'
|
||||
)}
|
||||
aria-selected={i === 2}
|
||||
>
|
||||
{i === 2 && <div className="absolute left-0 h-full w-0.5 bg-foreground" />}
|
||||
<EntityTypeIcon type={'r'} />
|
||||
{`postgres_table_${i}`}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</InnerSideBarEmptyPanel>
|
||||
)
|
||||
}
|
||||
80
apps/studio/components/ui/EntityTypeIcon.tsx
Normal file
80
apps/studio/components/ui/EntityTypeIcon.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { ENTITY_TYPE } from 'data/entity-types/entity-type-constants'
|
||||
import { Eye, GitBranch, Table2 } from 'lucide-react'
|
||||
import { cn, SQL_ICON } from 'ui'
|
||||
|
||||
interface EntityTypeIconProps {
|
||||
type: 'sql' | 'schema' | 'new' | 'r' | 'v' | 'm' | 'f' | 'p'
|
||||
size?: number
|
||||
strokeWidth?: number
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
export const EntityTypeIcon = ({
|
||||
type,
|
||||
size = 15,
|
||||
strokeWidth = 1.5,
|
||||
isActive,
|
||||
}: EntityTypeIconProps) => {
|
||||
if (type === 'sql') {
|
||||
return (
|
||||
<SQL_ICON
|
||||
size={size}
|
||||
className={cn(
|
||||
'transition-colors',
|
||||
'fill-foreground-muted',
|
||||
'group-aria-selected:fill-foreground',
|
||||
'w-4 h-4',
|
||||
'-ml-0.5'
|
||||
)}
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === ENTITY_TYPE.TABLE) {
|
||||
return (
|
||||
<Table2
|
||||
size={size}
|
||||
strokeWidth={strokeWidth}
|
||||
className={cn(
|
||||
'text-foreground-muted group-hover:text-foreground-lighter group-aria-selected:text-foreground',
|
||||
isActive && 'text-foreground-light',
|
||||
'transition-colors'
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === 'schema') {
|
||||
return <GitBranch size={size} strokeWidth={strokeWidth} />
|
||||
}
|
||||
|
||||
if (type === ENTITY_TYPE.VIEW) {
|
||||
return (
|
||||
<Eye
|
||||
size={size}
|
||||
strokeWidth={strokeWidth}
|
||||
className={cn(
|
||||
'text-foreground-muted group-hover:text-foreground-lighter',
|
||||
isActive && 'text-foreground-lighter',
|
||||
'transition-colors'
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center text-xs h-4 w-4 rounded-[2px] font-bold',
|
||||
type === ENTITY_TYPE.FOREIGN_TABLE && 'text-yellow-900 bg-yellow-500',
|
||||
type === ENTITY_TYPE.MATERIALIZED_VIEW && 'text-purple-1000 bg-purple-500',
|
||||
type === ENTITY_TYPE.PARTITIONED_TABLE && 'text-foreground-light bg-border-stronger'
|
||||
)}
|
||||
>
|
||||
{Object.entries(ENTITY_TYPE)
|
||||
.find(([, value]) => value === type)?.[0]?.[0]
|
||||
?.toUpperCase()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
import { propsAreEqual } from 'lib/helpers'
|
||||
import memoize from 'memoize-one'
|
||||
import { CSSProperties, ComponentType, MutableRefObject, ReactNode, memo, useRef } from 'react'
|
||||
import AutoSizer from 'react-virtualized-auto-sizer'
|
||||
import { VariableSizeList, areEqual } from 'react-window'
|
||||
import InfiniteLoader from 'react-window-infinite-loader'
|
||||
|
||||
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
|
||||
import { propsAreEqual } from 'lib/helpers'
|
||||
import { Skeleton } from 'ui'
|
||||
|
||||
/**
|
||||
* Note that the loading more logic of this component works best with a cursor-based
|
||||
@@ -58,9 +57,28 @@ const Item = memo(<T, P>({ data, index, style }: ItemProps<T, P>) => {
|
||||
<div style={style}>{LoaderComponent}</div>
|
||||
) : (
|
||||
<div className="space-y-1 my-1" style={style}>
|
||||
<ShimmeringLoader />
|
||||
<ShimmeringLoader />
|
||||
<ShimmeringLoader />
|
||||
<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>
|
||||
</div>
|
||||
)
|
||||
}, areEqual)
|
||||
@@ -92,35 +110,34 @@ function InfiniteList<T, P>({
|
||||
const itemData = createItemData(items, { itemProps, ItemComponent, LoaderComponent, listRef })
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col flex-grow">
|
||||
<div className="flex-grow">
|
||||
<AutoSizer>
|
||||
{({ height, width }: { height: number; width: number }) => (
|
||||
<InfiniteLoader
|
||||
itemCount={itemCount}
|
||||
isItemLoaded={isItemLoaded}
|
||||
loadMoreItems={loadMoreItems}
|
||||
>
|
||||
{({ onItemsRendered, ref }) => (
|
||||
<VariableSizeList
|
||||
ref={(refy) => {
|
||||
ref(refy)
|
||||
listRef.current = refy
|
||||
}}
|
||||
height={height ?? 0}
|
||||
width={width ?? 0}
|
||||
itemCount={itemCount}
|
||||
itemData={itemData}
|
||||
itemSize={getItemSize}
|
||||
onItemsRendered={onItemsRendered}
|
||||
>
|
||||
{Item}
|
||||
</VariableSizeList>
|
||||
)}
|
||||
</InfiniteLoader>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</div>
|
||||
<>
|
||||
<AutoSizer>
|
||||
{({ height, width }: { height: number; width: number }) => (
|
||||
<InfiniteLoader
|
||||
itemCount={itemCount}
|
||||
isItemLoaded={isItemLoaded}
|
||||
loadMoreItems={loadMoreItems}
|
||||
>
|
||||
{({ onItemsRendered, ref }) => (
|
||||
<VariableSizeList
|
||||
ref={(refy) => {
|
||||
ref(refy)
|
||||
listRef.current = refy
|
||||
}}
|
||||
height={height ?? 0}
|
||||
width={width ?? 0}
|
||||
itemCount={itemCount}
|
||||
itemData={itemData}
|
||||
itemSize={getItemSize}
|
||||
onItemsRendered={onItemsRendered}
|
||||
>
|
||||
{Item}
|
||||
</VariableSizeList>
|
||||
)}
|
||||
</InfiniteLoader>
|
||||
)}
|
||||
</AutoSizer>
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
@@ -131,7 +148,7 @@ function InfiniteList<T, P>({
|
||||
pointerEvents: 'none', //https://github.com/bvaughn/react-window/issues/455
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { Check, ChevronsUpDown, Plus } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import { useSchemasQuery } from 'data/database/schemas-query'
|
||||
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { Check, ChevronsUpDown, Plus } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
AlertDescription_Shadcn_,
|
||||
AlertTitle_Shadcn_,
|
||||
@@ -21,6 +20,7 @@ import {
|
||||
PopoverTrigger_Shadcn_,
|
||||
Popover_Shadcn_,
|
||||
ScrollArea,
|
||||
Skeleton,
|
||||
} from 'ui'
|
||||
|
||||
interface SchemaSelectorProps {
|
||||
@@ -69,8 +69,14 @@ const SchemaSelector = ({
|
||||
return (
|
||||
<div className={className}>
|
||||
{isSchemasLoading && (
|
||||
<Button type="default" className="justify-start" block size={size} loading>
|
||||
Loading schemas...
|
||||
<Button
|
||||
type="default"
|
||||
key="schema-selector-skeleton"
|
||||
className="w-full [&>span]:w-full"
|
||||
size={size}
|
||||
disabled
|
||||
>
|
||||
<Skeleton className="w-full h-3 bg-foreground-muted" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -95,14 +101,15 @@ const SchemaSelector = ({
|
||||
size={size}
|
||||
disabled={disabled}
|
||||
type="default"
|
||||
className={`w-full [&>span]:w-full`}
|
||||
data-testid="schema-selector"
|
||||
className={`w-full [&>span]:w-full !pr-1 space-x-1`}
|
||||
iconRight={
|
||||
<ChevronsUpDown className="text-foreground-muted" strokeWidth={2} size={14} />
|
||||
}
|
||||
>
|
||||
{selectedSchemaName ? (
|
||||
<div className="w-full flex gap-1">
|
||||
<p className="text-foreground-lighter">schema:</p>
|
||||
<p className="text-foreground-lighter">schema</p>
|
||||
<p className="text-foreground">
|
||||
{selectedSchemaName === '*' ? 'All schemas' : selectedSchemaName}
|
||||
</p>
|
||||
@@ -114,7 +121,12 @@ const SchemaSelector = ({
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger_Shadcn_>
|
||||
<PopoverContent_Shadcn_ className="p-0" side="bottom" align="start" sameWidthAsTrigger>
|
||||
<PopoverContent_Shadcn_
|
||||
className="p-0 min-w-[200px]"
|
||||
side="bottom"
|
||||
align="start"
|
||||
sameWidthAsTrigger
|
||||
>
|
||||
<Command_Shadcn_>
|
||||
<CommandInput_Shadcn_ placeholder="Find schema..." />
|
||||
<CommandList_Shadcn_>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
TreeViewItemVariant,
|
||||
cn,
|
||||
} from 'ui'
|
||||
import ShimmeringLoader from '../ShimmeringLoader'
|
||||
@@ -115,6 +116,37 @@ const InnerSideMenuItem = forwardRef<
|
||||
)
|
||||
})
|
||||
|
||||
const InnerSideMenuDataItem = forwardRef<
|
||||
ElementRef<typeof Link>,
|
||||
React.ComponentPropsWithoutRef<typeof Link> & {
|
||||
isActive?: boolean
|
||||
forceHoverState?: boolean | null
|
||||
isPreview?: boolean
|
||||
isOpened?: boolean
|
||||
}
|
||||
>(({ isActive = true, forceHoverState, isPreview, isOpened = true, ...props }, ref) => {
|
||||
return (
|
||||
<Link
|
||||
ref={ref}
|
||||
{...props}
|
||||
aria-current={isActive}
|
||||
className={cn(
|
||||
TreeViewItemVariant({
|
||||
isSelected: isActive && !isPreview,
|
||||
isOpened: isOpened && !isPreview,
|
||||
isPreview,
|
||||
}),
|
||||
'px-4',
|
||||
// forceHoverState && 'bg-surface-200',
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
{!isPreview && isActive && <div className="absolute left-0 h-full w-0.5 bg-foreground" />}
|
||||
{props.children}
|
||||
</Link>
|
||||
)
|
||||
})
|
||||
|
||||
function InnerSideMenuItemLoading({
|
||||
className,
|
||||
...props
|
||||
@@ -250,17 +282,18 @@ const InnerSideBarEmptyPanel = forwardRef<
|
||||
ref={ref}
|
||||
{...props}
|
||||
className={cn(
|
||||
'border bg-surface-100/50 flex flex-col gap-y-3 items-center justify-center rounded-md px-5 py-4',
|
||||
'border border-muted bg-surface-100 dark:bg-surface-75 flex flex-col gap-y-3 items-center justify-center rounded-md px-5 py-4',
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-y-1 items-center justify-center">
|
||||
<div className="w-full flex flex-col gap-y-1 items-center">
|
||||
{illustration}
|
||||
{title && <p className="text-xs text-foreground-light">{title}</p>}
|
||||
{description && (
|
||||
<p className="text-xs text-foreground-lighter text-center">{description}</p>
|
||||
)}
|
||||
{actions && <div className="mt-2">{actions}</div>}
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -277,6 +310,7 @@ export {
|
||||
InnerSideMenuCollapsible,
|
||||
InnerSideMenuCollapsibleContent,
|
||||
InnerSideMenuCollapsibleTrigger,
|
||||
InnerSideMenuDataItem,
|
||||
InnerSideMenuItem,
|
||||
InnerSideMenuItemLoading,
|
||||
InnerSideMenuSeparator,
|
||||
|
||||
@@ -5,9 +5,33 @@ import { ComponentPropsWithoutRef, ReactNode, forwardRef, useEffect, useRef, use
|
||||
import TreeViewPrimitive, { flattenTree } from 'react-accessible-treeview'
|
||||
import { cn } from '../../lib/utils'
|
||||
import { Input } from '../shadcn/ui/input'
|
||||
import { cva, VariantProps } from 'class-variance-authority'
|
||||
|
||||
const TreeView = TreeViewPrimitive
|
||||
|
||||
export type TreeViewItemVariantProps = VariantProps<typeof TreeViewItemVariant>
|
||||
export const TreeViewItemVariant = cva(
|
||||
// [Joshen Temp]: aria-selected:text-foreground not working as aria-selected property not rendered in DOM,
|
||||
// [Joshen Temp]: aria-selected:!bg-selection not working as aria-selected property not rendered in DOM
|
||||
'group relative transition-colors h-[28px] flex items-center gap-3 text-sm cursor-pointer select-none text-foreground-light hover:bg-control aria-expanded:bg-control data-[state=open]:bg-control', // data-[state=open]:bg-control bg state for context menu open
|
||||
{
|
||||
variants: {
|
||||
isSelected: {
|
||||
true: 'text-foreground !bg-selection', // bg state for context menu open
|
||||
false: '',
|
||||
},
|
||||
isOpened: {
|
||||
true: 'bg-control',
|
||||
false: '',
|
||||
},
|
||||
isPreview: {
|
||||
true: 'bg-control text-foreground',
|
||||
false: '',
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const TreeViewItem = forwardRef<
|
||||
HTMLDivElement,
|
||||
ComponentPropsWithoutRef<'div'> & {
|
||||
@@ -15,6 +39,10 @@ const TreeViewItem = forwardRef<
|
||||
level: number
|
||||
/** Specifies if the item is expanded */
|
||||
isExpanded?: boolean
|
||||
/** Specifies if the item is opened somewhere */
|
||||
isOpened?: boolean
|
||||
/** Specifies if the item is a preview */
|
||||
isPreview?: boolean
|
||||
/** Specifies if the item is a branch */
|
||||
isBranch?: boolean
|
||||
/** The padding for each level of the item */
|
||||
@@ -40,8 +68,10 @@ const TreeViewItem = forwardRef<
|
||||
level = 1,
|
||||
levelPadding = 56,
|
||||
isExpanded = false,
|
||||
isOpened = false,
|
||||
isBranch = false,
|
||||
isSelected = false,
|
||||
isPreview = false,
|
||||
isLoading = false,
|
||||
xPadding = 16,
|
||||
name = '',
|
||||
@@ -92,20 +122,7 @@ const TreeViewItem = forwardRef<
|
||||
aria-selected={isSelected}
|
||||
aria-expanded={!isEditing && isExpanded}
|
||||
{...props}
|
||||
className={cn(
|
||||
'group relative',
|
||||
'transition-colors',
|
||||
'h-[28px]',
|
||||
'flex items-center gap-3',
|
||||
'text-sm',
|
||||
'cursor-pointer select-none',
|
||||
'text-foreground-light',
|
||||
'hover:bg-control',
|
||||
'aria-expanded:bg-control',
|
||||
isSelected ? 'text-foreground' : '', // [Joshen Temp]: aria-selected:text-foreground not working as aria-selected property not rendered in DOM,
|
||||
isSelected ? '!bg-selection' : '', // [Joshen Temp]: aria-selected:!bg-selection not working as aria-selected property not rendered in DOM
|
||||
'data-[state=open]:bg-control' // bg state for context menu open
|
||||
)}
|
||||
className={cn(TreeViewItemVariant({ isSelected, isOpened, isPreview }))}
|
||||
style={{
|
||||
paddingLeft:
|
||||
level === 1 && !isBranch
|
||||
|
||||
@@ -108,7 +108,7 @@ test.describe('Table Editor page', () => {
|
||||
{ timeout: 0 }
|
||||
)
|
||||
|
||||
await page.getByRole('button', { name: 'schema: public' }).click()
|
||||
await page.getByTestId('schema-selector').click()
|
||||
await page.getByRole('option', { name: 'auth' }).click()
|
||||
|
||||
// wait for the table data to load for the auth schema
|
||||
|
||||
Reference in New Issue
Block a user