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