Skip to content

Commit 8e7781e

Browse files
refactor(database-ui): separate components out of data table
1 parent 55f25b6 commit 8e7781e

File tree

12 files changed

+1002
-640
lines changed

12 files changed

+1002
-640
lines changed

packages/cli/src/db-studio/ui/src/components/ColumnInfo.tsx renamed to packages/cli/src/db-studio/ui/src/components/ColumnHeader.tsx

Lines changed: 68 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import * as React from 'react'
2-
import { Key, Link } from 'lucide-react'
2+
import { Key, Link, ArrowUpDown, ArrowUpWideNarrow, ArrowDownWideNarrow } from 'lucide-react'
33
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'
44
import type { ColumnMetadata } from './DataTable'
55

6-
interface ColumnInfoProps {
6+
type ColumnInfoProps = {
77
column: ColumnMetadata
88
children: React.ReactNode
99
}
@@ -12,7 +12,7 @@ interface ColumnInfoProps {
1212
* Hover card component that displays detailed column metadata
1313
* Shows type, nullable status, constraints, foreign keys, etc.
1414
*/
15-
export function ColumnInfo({ column, children }: ColumnInfoProps) {
15+
function ColumnInfo({ column, children }: ColumnInfoProps) {
1616
return (
1717
<HoverCard openDelay={300}>
1818
<HoverCardTrigger asChild>{children}</HoverCardTrigger>
@@ -98,3 +98,68 @@ export function ColumnInfo({ column, children }: ColumnInfoProps) {
9898
</HoverCard>
9999
)
100100
}
101+
102+
type SortIconProps = {
103+
sorted: false | 'asc' | 'desc'
104+
className: string
105+
}
106+
107+
function SortIcon({ sorted, className }: SortIconProps) {
108+
switch (sorted) {
109+
case 'asc':
110+
return <ArrowUpWideNarrow className={className} />
111+
case 'desc':
112+
return <ArrowDownWideNarrow className={className} />
113+
default:
114+
return <ArrowUpDown className={className} />
115+
}
116+
}
117+
118+
type ColumnHeaderProps = {
119+
column: ColumnMetadata
120+
sorted: false | 'asc' | 'desc'
121+
showControls?: boolean
122+
onSortChange?: (column: string | null, direction: 'asc' | 'desc' | null) => void
123+
}
124+
125+
/**
126+
* Column header component that displays column name, type, and optional sorting controls
127+
* Wraps content in a ColumnInfo hover card for displaying detailed metadata
128+
*/
129+
export function ColumnHeader({ column, sorted, showControls = true, onSortChange }: ColumnHeaderProps) {
130+
const isKey = column.primaryKey
131+
const isForeignKey = !!column.foreignKey
132+
const isSortable = showControls && onSortChange
133+
134+
const handleSort = React.useCallback(() => {
135+
if (!onSortChange) return
136+
137+
// Three-state sorting: no sort → asc → desc → no sort
138+
if (!sorted) {
139+
onSortChange(column.name, 'asc')
140+
} else if (sorted === 'asc') {
141+
onSortChange(column.name, 'desc')
142+
} else {
143+
onSortChange(null, null)
144+
}
145+
}, [sorted, onSortChange, column.name])
146+
147+
return (
148+
<ColumnInfo column={column}>
149+
<div
150+
className={`flex items-center gap-1.5 px-4 py-2 w-full h-full ${
151+
isSortable ? 'cursor-pointer select-none hover:text-gray-200 transition-colors' : ''
152+
}`}
153+
onClick={isSortable ? handleSort : undefined}
154+
>
155+
{isKey && <Key className="w-3.5 h-3.5 text-yellow-400 shrink-0" />}
156+
{isForeignKey && <Link className="w-3.5 h-3.5 text-blue-400 shrink-0" />}
157+
<div className="flex flex-col gap-0.5 flex-1 min-w-0">
158+
<span className="text-xs font-semibold text-gray-200 tracking-wider truncate">{column.name}</span>
159+
<span className="text-[10px] text-gray-500 font-normal truncate">{column.type}</span>
160+
</div>
161+
{isSortable && <SortIcon sorted={sorted} className="h-3.5 w-3.5 text-gray-400 shrink-0" />}
162+
</div>
163+
</ColumnInfo>
164+
)
165+
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import * as React from 'react'
2+
import type { Table } from '@tanstack/react-table'
3+
import { Settings2, Search } from 'lucide-react'
4+
5+
import { Button } from '@/components/ui/button'
6+
import {
7+
DropdownMenu,
8+
DropdownMenuCheckboxItem,
9+
DropdownMenuContent,
10+
DropdownMenuLabel,
11+
DropdownMenuSeparator,
12+
DropdownMenuTrigger,
13+
} from '@/components/ui/dropdown-menu'
14+
import { Input } from '@/components/ui/input'
15+
import { Badge } from '@/components/ui/badge'
16+
17+
type ColumnsDropdownProps = {
18+
table: Table<Record<string, unknown>>
19+
columnSearch: string
20+
setColumnSearch: (value: string) => void
21+
}
22+
23+
export const ColumnsDropdown = React.memo(function ColumnsDropdown({
24+
table,
25+
columnSearch,
26+
setColumnSearch,
27+
}: ColumnsDropdownProps) {
28+
const hasHiddenColumns = Object.values(table.getState().columnVisibility).some((visible) => visible === false)
29+
30+
return (
31+
<DropdownMenu onOpenChange={(open) => !open && setColumnSearch('')}>
32+
<DropdownMenuTrigger asChild>
33+
<div className="relative">
34+
<Button variant="outline">
35+
<Settings2 className="mr-2 h-4 w-4" />
36+
Columns
37+
</Button>
38+
{hasHiddenColumns && (
39+
<Badge
40+
variant="secondary"
41+
className="absolute -top-1 -right-1 h-4 w-4 p-0 flex items-center justify-center text-[10px] rounded-full bg-white text-black"
42+
>
43+
!
44+
</Badge>
45+
)}
46+
</div>
47+
</DropdownMenuTrigger>
48+
<DropdownMenuContent align="center" className="w-[240px]" onCloseAutoFocus={(e) => e.preventDefault()}>
49+
<DropdownMenuLabel>Toggle columns</DropdownMenuLabel>
50+
<DropdownMenuSeparator />
51+
<div className="px-2 py-2" onKeyDown={(e) => e.stopPropagation()}>
52+
<div className="relative">
53+
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-gray-400" />
54+
<Input
55+
placeholder="Search..."
56+
value={columnSearch}
57+
onChange={(e) => setColumnSearch(e.target.value)}
58+
className="h-8 text-xs pl-8 w-full"
59+
onClick={(e) => e.stopPropagation()}
60+
onKeyDown={(e) => e.stopPropagation()}
61+
autoFocus
62+
/>
63+
</div>
64+
</div>
65+
66+
<DropdownMenuSeparator />
67+
<div className="max-h-[300px] overflow-y-auto">
68+
<Button
69+
variant="ghost"
70+
size="sm"
71+
className="h-7 w-full justify-start text-xs font-normal"
72+
onClick={(e) => {
73+
e.preventDefault()
74+
const hasHiddenColumns = table.getAllColumns().some((col) => !col.getIsVisible() && col.getCanHide())
75+
table.getAllColumns().forEach((col) => {
76+
if (col.getCanHide()) {
77+
col.toggleVisibility(hasHiddenColumns)
78+
}
79+
})
80+
}}
81+
>
82+
{table.getAllColumns().some((col) => !col.getIsVisible() && col.getCanHide())
83+
? 'Select all'
84+
: 'Deselect all'}
85+
</Button>
86+
{table
87+
.getAllColumns()
88+
.filter((column) => column.getCanHide())
89+
.filter((column) => column.id.toLowerCase().includes(columnSearch.toLowerCase()))
90+
.map((column) => {
91+
return (
92+
<DropdownMenuCheckboxItem
93+
key={column.id}
94+
checked={column.getIsVisible()}
95+
onCheckedChange={(value) => column.toggleVisibility(!!value)}
96+
onSelect={(e) => e.preventDefault()}
97+
>
98+
{column.id}
99+
</DropdownMenuCheckboxItem>
100+
)
101+
})}
102+
{table
103+
.getAllColumns()
104+
.filter((column) => column.getCanHide())
105+
.filter((column) => column.id.toLowerCase().includes(columnSearch.toLowerCase())).length === 0 && (
106+
<div className="px-2 py-2 text-xs text-gray-500 text-center">No columns found</div>
107+
)}
108+
</div>
109+
</DropdownMenuContent>
110+
</DropdownMenu>
111+
)
112+
})

0 commit comments

Comments
 (0)