TableEditorMenu.tsx323 lines · main
1import { PermissionAction } from '@supabase/shared-types/out/constants'
2import { keepPreviousData } from '@tanstack/react-query'
3import { useParams } from 'common'
4import { Filter, Plus } from 'lucide-react'
5import { useCallback, useEffect, useMemo, useState } from 'react'
6import { Button, Checkbox, Label, Popover, PopoverContent, PopoverTrigger } from 'ui'
7import {
8 InnerSideBarEmptyPanel,
9 InnerSideBarFilters,
10 InnerSideBarFilterSearchInput,
11 InnerSideBarFilterSortDropdown,
12 InnerSideBarFilterSortDropdownItem,
13} from 'ui-patterns/InnerSideMenu'
14
15import { useTableEditorTabsCleanUp } from '../Tabs/Tabs.utils'
16import { EntityListItem } from './EntityListItem'
17import { TableMenuEmptyState } from './TableMenuEmptyState'
18import { ExportDialog } from '@/components/grid/components/header/ExportDialog'
19import { parseSupaTable } from '@/components/grid/BrivenGrid.utils'
20import { SupaTable } from '@/components/grid/types'
21import { ProtectedSchemaWarning } from '@/components/interfaces/Database/ProtectedSchemaWarning'
22import { ErrorMatcher } from '@/components/interfaces/ErrorHandling/ErrorMatcher'
23import EditorMenuListSkeleton from '@/components/layouts/TableEditorLayout/EditorMenuListSkeleton'
24import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
25import { InfiniteListDefault, LoaderForIconMenuItems } from '@/components/ui/InfiniteList'
26import SchemaSelector from '@/components/ui/SchemaSelector'
27import { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants'
28import { useEntityTypesQuery } from '@/data/entity-types/entity-types-infinite-query'
29import { useTableApiAccessQuery } from '@/data/privileges/table-api-access-query'
30import { getTableEditor, useTableEditorQuery } from '@/data/table-editor/table-editor-query'
31import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
32import { useLocalStorage } from '@/hooks/misc/useLocalStorage'
33import { useQuerySchemaState } from '@/hooks/misc/useSchemaQueryState'
34import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
35import { useIsProtectedSchema } from '@/hooks/useProtectedSchemas'
36import { useTableEditorStateSnapshot } from '@/state/table-editor'
37
38export 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}