TableEditorMenu.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323
  1. import { PermissionAction } from '@supabase/shared-types/out/constants'
  2. import { keepPreviousData } from '@tanstack/react-query'
  3. import { useParams } from 'common'
  4. import { Filter, Plus } from 'lucide-react'
  5. import { useCallback, useEffect, useMemo, useState } from 'react'
  6. import { Button, Checkbox, Label, Popover, PopoverContent, PopoverTrigger } from 'ui'
  7. import {
  8. InnerSideBarEmptyPanel,
  9. InnerSideBarFilters,
  10. InnerSideBarFilterSearchInput,
  11. InnerSideBarFilterSortDropdown,
  12. InnerSideBarFilterSortDropdownItem,
  13. } from 'ui-patterns/InnerSideMenu'
  14. import { useTableEditorTabsCleanUp } from '../Tabs/Tabs.utils'
  15. import { EntityListItem } from './EntityListItem'
  16. import { TableMenuEmptyState } from './TableMenuEmptyState'
  17. import { ExportDialog } from '@/components/grid/components/header/ExportDialog'
  18. import { parseSupaTable } from '@/components/grid/BrivenGrid.utils'
  19. import { SupaTable } from '@/components/grid/types'
  20. import { ProtectedSchemaWarning } from '@/components/interfaces/Database/ProtectedSchemaWarning'
  21. import { ErrorMatcher } from '@/components/interfaces/ErrorHandling/ErrorMatcher'
  22. import EditorMenuListSkeleton from '@/components/layouts/TableEditorLayout/EditorMenuListSkeleton'
  23. import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
  24. import { InfiniteListDefault, LoaderForIconMenuItems } from '@/components/ui/InfiniteList'
  25. import SchemaSelector from '@/components/ui/SchemaSelector'
  26. import { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants'
  27. import { useEntityTypesQuery } from '@/data/entity-types/entity-types-infinite-query'
  28. import { useTableApiAccessQuery } from '@/data/privileges/table-api-access-query'
  29. import { getTableEditor, useTableEditorQuery } from '@/data/table-editor/table-editor-query'
  30. import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
  31. import { useLocalStorage } from '@/hooks/misc/useLocalStorage'
  32. import { useQuerySchemaState } from '@/hooks/misc/useSchemaQueryState'
  33. import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
  34. import { useIsProtectedSchema } from '@/hooks/useProtectedSchemas'
  35. import { useTableEditorStateSnapshot } from '@/state/table-editor'
  36. export const TableEditorMenu = () => {
  37. const { id: _id, ref: projectRef } = useParams()
  38. const id = _id ? Number(_id) : undefined
  39. const snap = useTableEditorStateSnapshot()
  40. const { selectedSchema, setSelectedSchema } = useQuerySchemaState()
  41. const [searchText, setSearchText] = useState<string>('')
  42. const [tableToExport, setTableToExport] = useState<SupaTable>()
  43. const [visibleTypes, setVisibleTypes] = useState<string[]>(Object.values(ENTITY_TYPE))
  44. const [sort, setSort] = useLocalStorage<'alphabetical' | 'grouped-alphabetical'>(
  45. 'table-editor-sort',
  46. 'alphabetical'
  47. )
  48. const { data: project } = useSelectedProjectQuery()
  49. const {
  50. data,
  51. isLoading,
  52. isSuccess,
  53. isError,
  54. error,
  55. hasNextPage,
  56. isFetchingNextPage,
  57. fetchNextPage,
  58. } = useEntityTypesQuery(
  59. {
  60. projectRef: project?.ref,
  61. connectionString: project?.connectionString,
  62. schemas: [selectedSchema],
  63. search: searchText.trim() || undefined,
  64. sort,
  65. filterTypes: visibleTypes,
  66. },
  67. {
  68. placeholderData: Boolean(searchText) ? keepPreviousData : undefined,
  69. }
  70. )
  71. const entityTypes = useMemo(
  72. () => data?.pages.flatMap((page) => page.data.entities),
  73. [data?.pages]
  74. )
  75. const entityNames = useMemo(() => entityTypes?.map((entity) => entity.name) ?? [], [entityTypes])
  76. const { data: apiAccessByTableName } = useTableApiAccessQuery(
  77. {
  78. projectRef: project?.ref,
  79. connectionString: project?.connectionString ?? undefined,
  80. schemaName: selectedSchema,
  81. tableNames: entityNames,
  82. },
  83. { enabled: Boolean(selectedSchema && entityNames.length > 0) }
  84. )
  85. const { can: canCreateTables } = useAsyncCheckPermissions(
  86. PermissionAction.TENANT_SQL_ADMIN_WRITE,
  87. 'tables'
  88. )
  89. const { isSchemaLocked } = useIsProtectedSchema({ schema: selectedSchema })
  90. const { data: selectedTable } = useTableEditorQuery({
  91. projectRef: project?.ref,
  92. connectionString: project?.connectionString,
  93. id,
  94. })
  95. if (selectedTable?.schema && !selectedSchema) {
  96. setSelectedSchema(selectedTable.schema)
  97. }
  98. const tableEditorTabsCleanUp = useTableEditorTabsCleanUp()
  99. const onSelectExportCLI = useCallback(
  100. async (id: number) => {
  101. const table = await getTableEditor({
  102. id: id,
  103. projectRef,
  104. connectionString: project?.connectionString,
  105. })
  106. const supaTable = table && parseSupaTable(table)
  107. setTableToExport(supaTable)
  108. },
  109. [project?.connectionString, projectRef]
  110. )
  111. const getItemKey = useCallback(
  112. (index: number) => {
  113. const item = entityTypes?.[index]
  114. return item?.id ? String(item.id) : `table-editor-entity-${index}`
  115. },
  116. [entityTypes]
  117. )
  118. const entityProps = useMemo(
  119. () => ({
  120. projectRef: project?.ref!,
  121. id: Number(id),
  122. isLocked: isSchemaLocked,
  123. onExportCLI: () => onSelectExportCLI(Number(id)),
  124. apiAccessMap: apiAccessByTableName,
  125. }),
  126. [project?.ref, id, isSchemaLocked, onSelectExportCLI, apiAccessByTableName]
  127. )
  128. useEffect(() => {
  129. // Clean up tabs + recent items for any tables that might have been removed outside of the dashboard session
  130. if (entityTypes && !searchText) {
  131. tableEditorTabsCleanUp({ schemas: [selectedSchema], entities: entityTypes })
  132. }
  133. }, [entityTypes, searchText, selectedSchema, tableEditorTabsCleanUp])
  134. return (
  135. <>
  136. <div className="flex flex-col grow gap-5 pt-5 h-full">
  137. <div className="flex flex-col gap-y-1.5">
  138. <SchemaSelector
  139. className="mx-4"
  140. selectedSchemaName={selectedSchema}
  141. onSelectSchema={(name: string) => {
  142. setSearchText('')
  143. setSelectedSchema(name)
  144. }}
  145. onSelectCreateSchema={() => snap.onAddSchema()}
  146. />
  147. <div className="grid gap-3 mx-4">
  148. {!isSchemaLocked ? (
  149. <ButtonTooltip
  150. block
  151. title="Create a new table"
  152. name="New table"
  153. disabled={!canCreateTables}
  154. size="tiny"
  155. icon={<Plus size={14} strokeWidth={1.5} className="text-foreground-muted" />}
  156. type="default"
  157. className="justify-start"
  158. onClick={() => snap.onAddTable()}
  159. tooltip={{
  160. content: {
  161. side: 'bottom',
  162. text: !canCreateTables
  163. ? 'You need additional permissions to create tables'
  164. : undefined,
  165. },
  166. }}
  167. >
  168. New table
  169. </ButtonTooltip>
  170. ) : (
  171. <ProtectedSchemaWarning size="sm" schema={selectedSchema} entity="table" />
  172. )}
  173. </div>
  174. </div>
  175. <div className="grow min-h-0 flex flex-col gap-2 pb-4">
  176. <InnerSideBarFilters className="mx-2">
  177. <InnerSideBarFilterSearchInput
  178. name="search-tables"
  179. value={searchText}
  180. placeholder="Search tables..."
  181. aria-labelledby="Search tables"
  182. onChange={(e) => setSearchText(e.target.value)}
  183. >
  184. <InnerSideBarFilterSortDropdown
  185. value={sort}
  186. onValueChange={(value: any) => setSort(value)}
  187. >
  188. <InnerSideBarFilterSortDropdownItem
  189. key="alphabetical"
  190. value="alphabetical"
  191. className="flex gap-2"
  192. >
  193. Alphabetical
  194. </InnerSideBarFilterSortDropdownItem>
  195. <InnerSideBarFilterSortDropdownItem
  196. key="grouped-alphabetical"
  197. value="grouped-alphabetical"
  198. >
  199. Entity Type
  200. </InnerSideBarFilterSortDropdownItem>
  201. </InnerSideBarFilterSortDropdown>
  202. </InnerSideBarFilterSearchInput>
  203. <Popover>
  204. <PopoverTrigger asChild>
  205. <Button
  206. type={visibleTypes.length !== 5 ? 'default' : 'dashed'}
  207. className="h-[32px] md:h-[28px] px-1.5"
  208. icon={<Filter />}
  209. />
  210. </PopoverTrigger>
  211. <PopoverContent className="p-0 w-56" side="bottom" align="center">
  212. <div className="px-3 pt-3 pb-2 flex flex-col gap-y-2">
  213. <p className="text-xs">Show entity types</p>
  214. <div className="flex flex-col">
  215. {Object.entries(ENTITY_TYPE).map(([key, value]) => (
  216. <div key={key} className="group flex items-center justify-between py-0.5">
  217. <div className="flex items-center gap-x-2">
  218. <Checkbox
  219. id={key}
  220. name={key}
  221. checked={visibleTypes.includes(value)}
  222. onCheckedChange={() => {
  223. if (visibleTypes.includes(value)) {
  224. setVisibleTypes(visibleTypes.filter((y) => y !== value))
  225. } else {
  226. setVisibleTypes(visibleTypes.concat([value]))
  227. }
  228. }}
  229. />
  230. <Label htmlFor={key} className="capitalize text-xs">
  231. {key.toLowerCase().replace('_', ' ')}
  232. </Label>
  233. </div>
  234. <Button
  235. size="tiny"
  236. type="default"
  237. onClick={() => setVisibleTypes([value])}
  238. className="transition opacity-0 group-hover:opacity-100 h-auto px-1 py-0.5"
  239. >
  240. Select only
  241. </Button>
  242. </div>
  243. ))}
  244. </div>
  245. </div>
  246. </PopoverContent>
  247. </Popover>
  248. </InnerSideBarFilters>
  249. {isLoading && <EditorMenuListSkeleton />}
  250. {isError && (
  251. <ErrorMatcher
  252. title="Failed to load tables"
  253. error={error ?? 'Failed to load tables'}
  254. supportFormParams={{ projectRef: project?.ref }}
  255. className="mx-4 mt-3"
  256. />
  257. )}
  258. {isSuccess && (
  259. <>
  260. {searchText.length === 0 && (entityTypes?.length ?? 0) <= 0 && (
  261. <TableMenuEmptyState />
  262. )}
  263. {searchText.length > 0 && (entityTypes?.length ?? 0) <= 0 && (
  264. <InnerSideBarEmptyPanel
  265. className="mx-2"
  266. title="No results found"
  267. description={`Your search for "${searchText}" did not return any results`}
  268. />
  269. )}
  270. {(entityTypes?.length ?? 0) > 0 && (
  271. <div className="flex flex-1 min-h-0 w-full" data-testid="tables-list">
  272. <InfiniteListDefault
  273. className="h-full w-full"
  274. items={entityTypes!}
  275. ItemComponent={EntityListItem}
  276. LoaderComponent={LoaderForIconMenuItems}
  277. itemProps={entityProps}
  278. getItemKey={getItemKey}
  279. getItemSize={(index) =>
  280. index !== 0 && index === entityTypes!.length ? 85 : 28
  281. }
  282. hasNextPage={hasNextPage}
  283. isLoadingNextPage={isFetchingNextPage}
  284. onLoadNextPage={fetchNextPage}
  285. />
  286. </div>
  287. )}
  288. </>
  289. )}
  290. </div>
  291. </div>
  292. <ExportDialog
  293. ignoreRoleImpersonation
  294. table={tableToExport}
  295. open={!!tableToExport}
  296. onOpenChange={(open) => {
  297. if (!open) setTableToExport(undefined)
  298. }}
  299. />
  300. </>
  301. )
  302. }