ColumnList.tsx422 lines · main
1import { PermissionAction } from '@supabase/shared-types/out/constants'
2import { useParams } from 'common'
3import { noop } from 'lodash'
4import {
5 Braces,
6 Calendar,
7 DiamondIcon,
8 Fingerprint,
9 Hash,
10 Key,
11 Link as LinkIcon,
12 ListPlus,
13 MoreVertical,
14 Plus,
15 Search,
16 ToggleRight,
17 Trash,
18 Type,
19} from 'lucide-react'
20import { useState } from 'react'
21import {
22 Button,
23 Card,
24 cn,
25 DropdownMenu,
26 DropdownMenuContent,
27 DropdownMenuTrigger,
28 Table,
29 TableBody,
30 TableCell,
31 TableFooter,
32 TableHead,
33 TableHeader,
34 TableRow,
35 Tooltip,
36 TooltipContent,
37 TooltipTrigger,
38} from 'ui'
39import { Input } from 'ui-patterns/DataInputs/Input'
40import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
41
42import { ProtectedSchemaWarning } from '../ProtectedSchemaWarning'
43import {
44 getColumnTypeAffordance,
45 getForeignKeyColumnNames,
46 getPrimaryKeyColumnNames,
47 getUniqueIndexColumnNames,
48} from './ColumnList.utils'
49import { ConstraintToken } from './ConstraintToken'
50import { displayColumnType } from '@/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.utils'
51import AlertError from '@/components/ui/AlertError'
52import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
53import { DropdownMenuItemTooltip } from '@/components/ui/DropdownMenuItemTooltip'
54import { NoSearchResults } from '@/components/ui/NoSearchResults'
55import { useTableEditorQuery } from '@/data/table-editor/table-editor-query'
56import { isTableLike } from '@/data/table-editor/table-editor-types'
57import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
58import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
59import { useIsProtectedSchema } from '@/hooks/useProtectedSchemas'
60import type { SafePostgresColumn } from '@/lib/postgres-types'
61
62const getColumnTypeAffordancePresentation = (column: SafePostgresColumn) => {
63 const { kind, label } = getColumnTypeAffordance(column.format)
64 const iconClassName = 'text-foreground-muted'
65
66 switch (kind) {
67 case 'number':
68 return {
69 icon: <Hash size={14} className={iconClassName} strokeWidth={1.5} />,
70 label,
71 }
72 case 'time':
73 return {
74 icon: <Calendar size={14} className={iconClassName} strokeWidth={1.5} />,
75 label,
76 }
77 case 'text':
78 return {
79 icon: <Type size={14} className={iconClassName} strokeWidth={1.5} />,
80 label,
81 }
82 case 'json':
83 return {
84 icon: <Braces size={14} className={iconClassName} strokeWidth={1.5} />,
85 label,
86 }
87 case 'bool':
88 return {
89 icon: <ToggleRight size={14} className={iconClassName} strokeWidth={1.5} />,
90 label,
91 }
92 default:
93 return {
94 icon: <ListPlus size={16} className={iconClassName} strokeWidth={1.5} />,
95 label,
96 }
97 }
98}
99
100interface ColumnListProps {
101 onAddColumn: () => void
102 onEditColumn: (column: SafePostgresColumn) => void
103 onDeleteColumn: (column: SafePostgresColumn) => void
104}
105
106export const ColumnList = ({
107 onAddColumn = noop,
108 onEditColumn = noop,
109 onDeleteColumn = noop,
110}: ColumnListProps) => {
111 const { id: _id } = useParams()
112 const id = _id ? Number(_id) : undefined
113
114 const { data: project } = useSelectedProjectQuery()
115 const {
116 data: selectedTable,
117 error,
118 isError,
119 isPending: isLoading,
120 isSuccess,
121 } = useTableEditorQuery({
122 projectRef: project?.ref,
123 connectionString: project?.connectionString,
124 id,
125 })
126
127 const [filterString, setFilterString] = useState<string>('')
128 const isTableEntity = isTableLike(selectedTable)
129 const tableConstraintSource = isTableEntity ? selectedTable : undefined
130 const primaryKeyColumns = getPrimaryKeyColumnNames(tableConstraintSource)
131 const foreignKeyColumns = getForeignKeyColumnNames(tableConstraintSource)
132 const uniqueIndexColumns = getUniqueIndexColumnNames(tableConstraintSource)
133
134 const columns =
135 (filterString.length === 0
136 ? (selectedTable?.columns ?? [])
137 : selectedTable?.columns?.filter((column) =>
138 column.name.toLowerCase().includes(filterString.toLowerCase())
139 )) ?? []
140
141 const { isSchemaLocked } = useIsProtectedSchema({ schema: selectedTable?.schema ?? '' })
142 const { can: canUpdateColumns } = useAsyncCheckPermissions(
143 PermissionAction.TENANT_SQL_ADMIN_WRITE,
144 'columns'
145 )
146 const deleteColumnTooltipText = !canUpdateColumns
147 ? 'Additional permissions required to delete column'
148 : undefined
149
150 return (
151 <div className="space-y-4">
152 <div className="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
153 <div className="w-full lg:w-52">
154 <Input
155 size="tiny"
156 placeholder="Filter columns"
157 value={filterString}
158 onChange={(e) => setFilterString(e.target.value)}
159 icon={<Search />}
160 />
161 </div>
162 {!isSchemaLocked && isTableEntity && (
163 <ButtonTooltip
164 icon={<Plus />}
165 disabled={!canUpdateColumns}
166 onClick={() => onAddColumn()}
167 tooltip={{
168 content: {
169 side: 'bottom',
170 text: !canUpdateColumns
171 ? 'You need additional permissions to create columns'
172 : undefined,
173 },
174 }}
175 >
176 New column
177 </ButtonTooltip>
178 )}
179 </div>
180
181 {isSchemaLocked && (
182 <ProtectedSchemaWarning schema={selectedTable?.schema ?? ''} entity="columns" />
183 )}
184
185 <Card>
186 {isLoading ? (
187 <div className="p-4">
188 <GenericSkeletonLoader />
189 </div>
190 ) : (
191 <Table>
192 <TableHeader>
193 <TableRow>
194 <TableHead className="w-0 px-0!" />
195
196 <TableHead
197 className={cn(columns.length === 0 ? 'text-foreground-muted' : undefined)}
198 >
199 Name
200 </TableHead>
201 <TableHead className={columns.length === 0 ? 'text-foreground-muted' : undefined}>
202 Type
203 </TableHead>
204 <TableHead className={columns.length === 0 ? 'text-foreground-muted' : undefined}>
205 Constraints
206 </TableHead>
207 <TableHead />
208 </TableRow>
209 </TableHeader>
210
211 <TableBody>
212 {isError && (
213 <TableRow className="[&>td]:hover:bg-inherit">
214 <TableCell colSpan={5}>
215 <AlertError
216 error={error}
217 subject={`Failed to retrieve columns for table "${selectedTable?.schema}.${selectedTable?.name}"`}
218 />
219 </TableCell>
220 </TableRow>
221 )}
222
223 {isSuccess && columns.length === 0 && filterString.length > 0 && (
224 <TableRow className="[&>td]:hover:bg-inherit">
225 <TableCell colSpan={5}>
226 <NoSearchResults
227 withinTableCell
228 searchString={filterString}
229 onResetFilter={() => setFilterString('')}
230 />
231 </TableCell>
232 </TableRow>
233 )}
234
235 {isSuccess && columns.length === 0 && filterString.length === 0 && (
236 <TableRow className="[&>td]:hover:bg-inherit">
237 <TableCell colSpan={5}>
238 <p className="text-sm text-foreground">No columns created yet</p>
239 <p className="text-sm text-foreground-light">
240 There are no columns in "{selectedTable?.schema}.{selectedTable?.name}"
241 </p>
242 </TableCell>
243 </TableRow>
244 )}
245
246 {isSuccess &&
247 columns.map((column) => {
248 const { icon: TypeIcon, label: typeLabel } =
249 getColumnTypeAffordancePresentation(column)
250
251 const constraintTokens = [
252 primaryKeyColumns.has(column.name) ? (
253 <ConstraintToken
254 key="primary"
255 icon={<Key size={12} strokeWidth={1.7} className="shrink-0" />}
256 label="Primary"
257 variant="primary"
258 />
259 ) : null,
260 foreignKeyColumns.has(column.name) ? (
261 <ConstraintToken
262 key="foreign-key"
263 icon={
264 <LinkIcon
265 size={12}
266 strokeWidth={1.7}
267 className="shrink-0 text-foreground-muted"
268 />
269 }
270 label="Foreign key"
271 />
272 ) : null,
273 column.is_unique || uniqueIndexColumns.has(column.name) ? (
274 <ConstraintToken
275 key="unique"
276 icon={
277 <Fingerprint
278 size={12}
279 strokeWidth={1.7}
280 className="shrink-0 text-foreground-light"
281 />
282 }
283 label="Unique"
284 />
285 ) : null,
286 column.is_identity ? (
287 <ConstraintToken
288 key="identity"
289 icon={
290 <Hash
291 size={12}
292 strokeWidth={1.7}
293 className="shrink-0 text-foreground-lighter"
294 />
295 }
296 label="Identity"
297 />
298 ) : null,
299 <ConstraintToken
300 key="nullability"
301 icon={
302 <DiamondIcon
303 size={12}
304 strokeWidth={1.7}
305 className="shrink-0"
306 fill={column.is_nullable ? 'none' : 'currentColor'}
307 />
308 }
309 label={column.is_nullable ? 'Nullable' : 'Non-nullable'}
310 variant="secondary"
311 />,
312 ].filter(Boolean)
313
314 return (
315 <TableRow key={column.name}>
316 <TableCell className="w-0 pl-5! pr-1!">
317 <Tooltip>
318 <TooltipTrigger asChild className="cursor-default" aria-label={typeLabel}>
319 <div className="flex w-4 justify-center">{TypeIcon}</div>
320 </TooltipTrigger>
321 <TooltipContent side="bottom">
322 <div className="flex flex-col">
323 <span>{column.data_type}</span>
324 {column.format !== column.data_type && (
325 <span className="text-xs text-foreground-light">
326 {displayColumnType(
327 column.format,
328 column.format_schema,
329 column.data_type === 'ARRAY'
330 )}
331 </span>
332 )}
333 </div>
334 </TooltipContent>
335 </Tooltip>
336 </TableCell>
337 <TableCell className="max-w-[160px] sm:max-w-[280px]">
338 <div className="flex min-w-0 flex-col">
339 <p>{column.name}</p>
340 {column.comment !== null ? (
341 <span
342 className="max-w-md truncate text-foreground-lighter"
343 title={column.comment}
344 >
345 {column.comment}
346 </span>
347 ) : null}
348 </div>
349 </TableCell>
350 <TableCell>
351 <p className="text-foreground-lighter">
352 {displayColumnType(
353 column.format,
354 column.format_schema,
355 column.data_type === 'ARRAY'
356 )}
357 </p>
358 </TableCell>
359 <TableCell>
360 <div className="flex flex-wrap gap-1.5">{constraintTokens}</div>
361 </TableCell>
362 <TableCell className="text-right">
363 {!isSchemaLocked && isTableEntity && (
364 <div className="flex justify-end gap-2">
365 <ButtonTooltip
366 type="default"
367 disabled={!canUpdateColumns}
368 onClick={() => onEditColumn(column)}
369 tooltip={{
370 content: {
371 side: 'bottom',
372 text: !canUpdateColumns
373 ? 'Additional permissions required to edit column'
374 : undefined,
375 },
376 }}
377 >
378 Edit
379 </ButtonTooltip>
380 <DropdownMenu>
381 <DropdownMenuTrigger asChild>
382 <Button type="default" className="px-1" icon={<MoreVertical />} />
383 </DropdownMenuTrigger>
384 <DropdownMenuContent side="bottom" align="end" className="w-32">
385 <DropdownMenuItemTooltip
386 disabled={!canUpdateColumns}
387 onClick={() => onDeleteColumn(column)}
388 className="gap-x-2"
389 tooltip={{
390 content: {
391 side: 'left',
392 text: deleteColumnTooltipText,
393 },
394 }}
395 >
396 <Trash size={12} />
397 <p>Delete column</p>
398 </DropdownMenuItemTooltip>
399 </DropdownMenuContent>
400 </DropdownMenu>
401 </div>
402 )}
403 </TableCell>
404 </TableRow>
405 )
406 })}
407 </TableBody>
408 {isSuccess && (
409 <TableFooter className="font-normal">
410 <TableRow className="border-b-0 [&>td]:hover:bg-inherit">
411 <TableCell colSpan={5} className="text-foreground-muted">
412 {columns.length} {columns.length === 1 ? 'column' : 'columns'}
413 </TableCell>
414 </TableRow>
415 </TableFooter>
416 )}
417 </Table>
418 )}
419 </Card>
420 </div>
421 )
422}