| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323 |
- import { PermissionAction } from '@supabase/shared-types/out/constants'
- import { keepPreviousData } from '@tanstack/react-query'
- import { useParams } from 'common'
- import { Filter, Plus } from 'lucide-react'
- import { useCallback, useEffect, useMemo, useState } from 'react'
- import { Button, Checkbox, Label, Popover, PopoverContent, PopoverTrigger } from 'ui'
- import {
- InnerSideBarEmptyPanel,
- InnerSideBarFilters,
- InnerSideBarFilterSearchInput,
- InnerSideBarFilterSortDropdown,
- InnerSideBarFilterSortDropdownItem,
- } from 'ui-patterns/InnerSideMenu'
- import { useTableEditorTabsCleanUp } from '../Tabs/Tabs.utils'
- import { EntityListItem } from './EntityListItem'
- import { TableMenuEmptyState } from './TableMenuEmptyState'
- import { ExportDialog } from '@/components/grid/components/header/ExportDialog'
- import { parseSupaTable } from '@/components/grid/BrivenGrid.utils'
- import { SupaTable } from '@/components/grid/types'
- import { ProtectedSchemaWarning } from '@/components/interfaces/Database/ProtectedSchemaWarning'
- import { ErrorMatcher } from '@/components/interfaces/ErrorHandling/ErrorMatcher'
- import EditorMenuListSkeleton from '@/components/layouts/TableEditorLayout/EditorMenuListSkeleton'
- import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
- import { InfiniteListDefault, LoaderForIconMenuItems } from '@/components/ui/InfiniteList'
- import SchemaSelector from '@/components/ui/SchemaSelector'
- import { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants'
- import { useEntityTypesQuery } from '@/data/entity-types/entity-types-infinite-query'
- import { useTableApiAccessQuery } from '@/data/privileges/table-api-access-query'
- import { getTableEditor, useTableEditorQuery } from '@/data/table-editor/table-editor-query'
- import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
- import { useLocalStorage } from '@/hooks/misc/useLocalStorage'
- import { useQuerySchemaState } from '@/hooks/misc/useSchemaQueryState'
- import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
- import { useIsProtectedSchema } from '@/hooks/useProtectedSchemas'
- import { useTableEditorStateSnapshot } from '@/state/table-editor'
- export const TableEditorMenu = () => {
- const { id: _id, ref: projectRef } = useParams()
- const id = _id ? Number(_id) : undefined
- const snap = useTableEditorStateSnapshot()
- const { selectedSchema, setSelectedSchema } = useQuerySchemaState()
- const [searchText, setSearchText] = useState<string>('')
- const [tableToExport, setTableToExport] = useState<SupaTable>()
- const [visibleTypes, setVisibleTypes] = useState<string[]>(Object.values(ENTITY_TYPE))
- const [sort, setSort] = useLocalStorage<'alphabetical' | 'grouped-alphabetical'>(
- 'table-editor-sort',
- 'alphabetical'
- )
- const { data: project } = useSelectedProjectQuery()
- 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,
- },
- {
- placeholderData: Boolean(searchText) ? keepPreviousData : undefined,
- }
- )
- const entityTypes = useMemo(
- () => data?.pages.flatMap((page) => page.data.entities),
- [data?.pages]
- )
- const entityNames = useMemo(() => entityTypes?.map((entity) => entity.name) ?? [], [entityTypes])
- const { data: apiAccessByTableName } = useTableApiAccessQuery(
- {
- projectRef: project?.ref,
- connectionString: project?.connectionString ?? undefined,
- schemaName: selectedSchema,
- tableNames: entityNames,
- },
- { enabled: Boolean(selectedSchema && entityNames.length > 0) }
- )
- const { can: canCreateTables } = useAsyncCheckPermissions(
- PermissionAction.TENANT_SQL_ADMIN_WRITE,
- 'tables'
- )
- const { isSchemaLocked } = useIsProtectedSchema({ schema: selectedSchema })
- const { data: selectedTable } = useTableEditorQuery({
- projectRef: project?.ref,
- connectionString: project?.connectionString,
- id,
- })
- if (selectedTable?.schema && !selectedSchema) {
- setSelectedSchema(selectedTable.schema)
- }
- const tableEditorTabsCleanUp = useTableEditorTabsCleanUp()
- const onSelectExportCLI = useCallback(
- async (id: number) => {
- const table = await getTableEditor({
- id: id,
- projectRef,
- connectionString: project?.connectionString,
- })
- const supaTable = table && parseSupaTable(table)
- setTableToExport(supaTable)
- },
- [project?.connectionString, projectRef]
- )
- const getItemKey = useCallback(
- (index: number) => {
- const item = entityTypes?.[index]
- return item?.id ? String(item.id) : `table-editor-entity-${index}`
- },
- [entityTypes]
- )
- const entityProps = useMemo(
- () => ({
- projectRef: project?.ref!,
- id: Number(id),
- isLocked: isSchemaLocked,
- onExportCLI: () => onSelectExportCLI(Number(id)),
- apiAccessMap: apiAccessByTableName,
- }),
- [project?.ref, id, isSchemaLocked, onSelectExportCLI, apiAccessByTableName]
- )
- useEffect(() => {
- // Clean up tabs + recent items for any tables that might have been removed outside of the dashboard session
- if (entityTypes && !searchText) {
- tableEditorTabsCleanUp({ schemas: [selectedSchema], entities: entityTypes })
- }
- }, [entityTypes, searchText, selectedSchema, tableEditorTabsCleanUp])
- return (
- <>
- <div className="flex flex-col grow gap-5 pt-5 h-full">
- <div className="flex flex-col gap-y-1.5">
- <SchemaSelector
- className="mx-4"
- selectedSchemaName={selectedSchema}
- onSelectSchema={(name: string) => {
- setSearchText('')
- setSelectedSchema(name)
- }}
- onSelectCreateSchema={() => snap.onAddSchema()}
- />
- <div className="grid gap-3 mx-4">
- {!isSchemaLocked ? (
- <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>
- ) : (
- <ProtectedSchemaWarning size="sm" schema={selectedSchema} entity="table" />
- )}
- </div>
- </div>
- <div className="grow min-h-0 flex flex-col gap-2 pb-4">
- <InnerSideBarFilters className="mx-2">
- <InnerSideBarFilterSearchInput
- name="search-tables"
- value={searchText}
- placeholder="Search tables..."
- aria-labelledby="Search tables"
- onChange={(e) => setSearchText(e.target.value)}
- >
- <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>
- <PopoverTrigger asChild>
- <Button
- type={visibleTypes.length !== 5 ? 'default' : 'dashed'}
- className="h-[32px] md:h-[28px] px-1.5"
- icon={<Filter />}
- />
- </PopoverTrigger>
- <PopoverContent 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
- 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 htmlFor={key} className="capitalize text-xs">
- {key.toLowerCase().replace('_', ' ')}
- </Label>
- </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>
- </Popover>
- </InnerSideBarFilters>
- {isLoading && <EditorMenuListSkeleton />}
- {isError && (
- <ErrorMatcher
- title="Failed to load tables"
- error={error ?? 'Failed to load tables'}
- supportFormParams={{ projectRef: project?.ref }}
- className="mx-4 mt-3"
- />
- )}
- {isSuccess && (
- <>
- {searchText.length === 0 && (entityTypes?.length ?? 0) <= 0 && (
- <TableMenuEmptyState />
- )}
- {searchText.length > 0 && (entityTypes?.length ?? 0) <= 0 && (
- <InnerSideBarEmptyPanel
- className="mx-2"
- title="No results found"
- description={`Your search for "${searchText}" did not return any results`}
- />
- )}
- {(entityTypes?.length ?? 0) > 0 && (
- <div className="flex flex-1 min-h-0 w-full" data-testid="tables-list">
- <InfiniteListDefault
- className="h-full w-full"
- items={entityTypes!}
- ItemComponent={EntityListItem}
- LoaderComponent={LoaderForIconMenuItems}
- itemProps={entityProps}
- getItemKey={getItemKey}
- getItemSize={(index) =>
- index !== 0 && index === entityTypes!.length ? 85 : 28
- }
- hasNextPage={hasNextPage}
- isLoadingNextPage={isFetchingNextPage}
- onLoadNextPage={fetchNextPage}
- />
- </div>
- )}
- </>
- )}
- </div>
- </div>
- <ExportDialog
- ignoreRoleImpersonation
- table={tableToExport}
- open={!!tableToExport}
- onOpenChange={(open) => {
- if (!open) setTableToExport(undefined)
- }}
- />
- </>
- )
- }
|