Skip to content

Commit b164b03

Browse files
committed
✨ Add DataTable for item and admin management
1 parent b07fe9f commit b164b03

File tree

5 files changed

+406
-223
lines changed

5 files changed

+406
-223
lines changed
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import type { ColumnDef } from "@tanstack/react-table"
2+
import type { UserPublic } from "@/client"
3+
import { Badge } from "@/components/ui/badge"
4+
import { UserActionsMenu } from "./UserActionsMenu"
5+
6+
export type UserTableData = UserPublic & {
7+
isCurrentUser: boolean
8+
}
9+
10+
export const columns: ColumnDef<UserTableData>[] = [
11+
{
12+
accessorKey: "full_name",
13+
header: "Full Name",
14+
cell: ({ row }) => {
15+
const fullName = row.original.full_name
16+
return (
17+
<div className="flex items-center gap-2">
18+
<span
19+
className={`font-medium ${!fullName ? "text-muted-foreground" : ""}`}
20+
>
21+
{fullName || "N/A"}
22+
</span>
23+
{row.original.isCurrentUser && (
24+
<Badge variant="outline" className="text-xs">
25+
You
26+
</Badge>
27+
)}
28+
</div>
29+
)
30+
},
31+
},
32+
{
33+
accessorKey: "email",
34+
header: "Email",
35+
cell: ({ row }) => (
36+
<span className="text-muted-foreground">{row.original.email}</span>
37+
),
38+
},
39+
{
40+
accessorKey: "is_superuser",
41+
header: "Role",
42+
cell: ({ row }) => (
43+
<Badge variant={row.original.is_superuser ? "default" : "secondary"}>
44+
{row.original.is_superuser ? "Superuser" : "User"}
45+
</Badge>
46+
),
47+
},
48+
{
49+
accessorKey: "is_active",
50+
header: "Status",
51+
cell: ({ row }) => (
52+
<div className="flex items-center gap-2">
53+
<span
54+
className={`size-2 rounded-full ${row.original.is_active ? "bg-green-500" : "bg-gray-400"}`}
55+
/>
56+
<span className={row.original.is_active ? "" : "text-muted-foreground"}>
57+
{row.original.is_active ? "Active" : "Inactive"}
58+
</span>
59+
</div>
60+
),
61+
},
62+
{
63+
id: "actions",
64+
header: () => <span className="sr-only">Actions</span>,
65+
cell: ({ row }) => (
66+
<div className="flex justify-end">
67+
<UserActionsMenu
68+
user={row.original}
69+
disabled={row.original.isCurrentUser}
70+
/>
71+
</div>
72+
),
73+
},
74+
]
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import {
2+
type ColumnDef,
3+
flexRender,
4+
getCoreRowModel,
5+
getPaginationRowModel,
6+
useReactTable,
7+
} from "@tanstack/react-table"
8+
import {
9+
ChevronLeft,
10+
ChevronRight,
11+
ChevronsLeft,
12+
ChevronsRight,
13+
} from "lucide-react"
14+
15+
import { Button } from "@/components/ui/button"
16+
import {
17+
Select,
18+
SelectContent,
19+
SelectItem,
20+
SelectTrigger,
21+
SelectValue,
22+
} from "@/components/ui/select"
23+
import {
24+
Table,
25+
TableBody,
26+
TableCell,
27+
TableHead,
28+
TableHeader,
29+
TableRow,
30+
} from "@/components/ui/table"
31+
32+
interface DataTableProps<TData, TValue> {
33+
columns: ColumnDef<TData, TValue>[]
34+
data: TData[]
35+
}
36+
37+
export function DataTable<TData, TValue>({
38+
columns,
39+
data,
40+
}: DataTableProps<TData, TValue>) {
41+
const table = useReactTable({
42+
data,
43+
columns,
44+
getCoreRowModel: getCoreRowModel(),
45+
getPaginationRowModel: getPaginationRowModel(),
46+
})
47+
48+
return (
49+
<div className="flex flex-col gap-4">
50+
<Table>
51+
<TableHeader>
52+
{table.getHeaderGroups().map((headerGroup) => (
53+
<TableRow key={headerGroup.id} className="hover:bg-transparent">
54+
{headerGroup.headers.map((header) => {
55+
return (
56+
<TableHead key={header.id}>
57+
{header.isPlaceholder
58+
? null
59+
: flexRender(
60+
header.column.columnDef.header,
61+
header.getContext(),
62+
)}
63+
</TableHead>
64+
)
65+
})}
66+
</TableRow>
67+
))}
68+
</TableHeader>
69+
<TableBody>
70+
{table.getRowModel().rows.length ? (
71+
table.getRowModel().rows.map((row) => (
72+
<TableRow
73+
key={row.id}
74+
>
75+
{row.getVisibleCells().map((cell) => (
76+
<TableCell key={cell.id}>
77+
{flexRender(cell.column.columnDef.cell, cell.getContext())}
78+
</TableCell>
79+
))}
80+
</TableRow>
81+
))
82+
) : (
83+
<TableRow className="hover:bg-transparent">
84+
<TableCell
85+
colSpan={columns.length}
86+
className="h-32 text-center text-muted-foreground"
87+
>
88+
No results found.
89+
</TableCell>
90+
</TableRow>
91+
)}
92+
</TableBody>
93+
</Table>
94+
95+
{table.getPageCount() > 1 && (
96+
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 p-4 border-t bg-muted/20">
97+
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
98+
<div className="text-sm text-muted-foreground">
99+
Showing{" "}
100+
{table.getState().pagination.pageIndex *
101+
table.getState().pagination.pageSize +
102+
1}{" "}
103+
to{" "}
104+
{Math.min(
105+
(table.getState().pagination.pageIndex + 1) *
106+
table.getState().pagination.pageSize,
107+
data.length,
108+
)}{" "}
109+
of{" "}
110+
<span className="font-medium text-foreground">{data.length}</span>{" "}
111+
entries
112+
</div>
113+
<div className="flex items-center gap-x-2">
114+
<p className="text-sm text-muted-foreground">Rows per page</p>
115+
<Select
116+
value={`${table.getState().pagination.pageSize}`}
117+
onValueChange={(value) => {
118+
table.setPageSize(Number(value))
119+
}}
120+
>
121+
<SelectTrigger className="h-8 w-[70px]">
122+
<SelectValue
123+
placeholder={table.getState().pagination.pageSize}
124+
/>
125+
</SelectTrigger>
126+
<SelectContent side="top">
127+
{[5, 10, 25, 50].map((pageSize) => (
128+
<SelectItem key={pageSize} value={`${pageSize}`}>
129+
{pageSize}
130+
</SelectItem>
131+
))}
132+
</SelectContent>
133+
</Select>
134+
</div>
135+
</div>
136+
137+
<div className="flex items-center gap-x-6">
138+
<div className="flex items-center gap-x-1 text-sm text-muted-foreground">
139+
<span>Page</span>
140+
<span className="font-medium text-foreground">
141+
{table.getState().pagination.pageIndex + 1}
142+
</span>
143+
<span>of</span>
144+
<span className="font-medium text-foreground">
145+
{table.getPageCount()}
146+
</span>
147+
</div>
148+
149+
<div className="flex items-center gap-x-1">
150+
<Button
151+
variant="outline"
152+
size="sm"
153+
className="h-8 w-8 p-0"
154+
onClick={() => table.setPageIndex(0)}
155+
disabled={!table.getCanPreviousPage()}
156+
>
157+
<span className="sr-only">Go to first page</span>
158+
<ChevronsLeft className="h-4 w-4" />
159+
</Button>
160+
<Button
161+
variant="outline"
162+
size="sm"
163+
className="h-8 w-8 p-0"
164+
onClick={() => table.previousPage()}
165+
disabled={!table.getCanPreviousPage()}
166+
>
167+
<span className="sr-only">Go to previous page</span>
168+
<ChevronLeft className="h-4 w-4" />
169+
</Button>
170+
<Button
171+
variant="outline"
172+
size="sm"
173+
className="h-8 w-8 p-0"
174+
onClick={() => table.nextPage()}
175+
disabled={!table.getCanNextPage()}
176+
>
177+
<span className="sr-only">Go to next page</span>
178+
<ChevronRight className="h-4 w-4" />
179+
</Button>
180+
<Button
181+
variant="outline"
182+
size="sm"
183+
className="h-8 w-8 p-0"
184+
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
185+
disabled={!table.getCanNextPage()}
186+
>
187+
<span className="sr-only">Go to last page</span>
188+
<ChevronsRight className="h-4 w-4" />
189+
</Button>
190+
</div>
191+
</div>
192+
</div>
193+
)}
194+
</div>
195+
)
196+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import type { ColumnDef } from "@tanstack/react-table"
2+
import { Check, Copy } from "lucide-react"
3+
import type { ItemPublic } from "@/client"
4+
import { Button } from "@/components/ui/button"
5+
import { useCopyToClipboard } from "@/hooks/useCopyToClipboard"
6+
import { ItemActionsMenu } from "./ItemActionsMenu"
7+
8+
function CopyId({ id }: { id: string }) {
9+
const [copiedText, copy] = useCopyToClipboard()
10+
const isCopied = copiedText === id
11+
12+
return (
13+
<div className="flex items-center gap-1.5 group">
14+
<span className="font-mono text-xs text-muted-foreground">{id}</span>
15+
<Button
16+
variant="ghost"
17+
size="icon"
18+
className="size-6 opacity-0 group-hover:opacity-100 transition-opacity"
19+
onClick={() => copy(id)}
20+
>
21+
{isCopied ? (
22+
<Check className="size-3 text-green-500" />
23+
) : (
24+
<Copy className="size-3" />
25+
)}
26+
<span className="sr-only">Copy ID</span>
27+
</Button>
28+
</div>
29+
)
30+
}
31+
32+
export const columns: ColumnDef<ItemPublic>[] = [
33+
{
34+
accessorKey: "id",
35+
header: "ID",
36+
cell: ({ row }) => <CopyId id={row.original.id} />,
37+
},
38+
{
39+
accessorKey: "title",
40+
header: "Title",
41+
cell: ({ row }) => (
42+
<span className="font-medium">{row.original.title}</span>
43+
),
44+
},
45+
{
46+
accessorKey: "description",
47+
header: "Description",
48+
cell: ({ row }) => {
49+
const description = row.original.description
50+
return (
51+
<span
52+
className={`max-w-xs truncate block ${!description ? "text-muted-foreground italic" : "text-muted-foreground"}`}
53+
>
54+
{description || "No description"}
55+
</span>
56+
)
57+
},
58+
},
59+
{
60+
id: "actions",
61+
header: () => <span className="sr-only">Actions</span>,
62+
cell: ({ row }) => (
63+
<div className="flex justify-end">
64+
<ItemActionsMenu item={row.original} />
65+
</div>
66+
),
67+
},
68+
]

0 commit comments

Comments
 (0)