@suphark/ui 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  รูปแบบตาม [Keep a Changelog](https://keepachangelog.com/) · semver: patch = หน้าตา/บั๊ก, minor = component/prop ใหม่, major = เปลี่ยน/ลบ props
4
4
 
5
+ ## [0.2.0] — 2026-09-18
6
+
7
+ ### Added
8
+
9
+ - entry **`@suphark/ui/data-table`** (ยกจาก supplychain `components/data-table`): `DataTable`, `DataTableColumnHeader`, `DataTableToolbar`, `DataTablePagination`, `DataTableFacetedFilter`, `DataTableDateRangeFilter`, `DataTableViewOptions`, `DataTableRowActions`, `useDataTable`, types (`DataTableProps`, `FacetedFilterConfig`) + `DeleteManyButton`, `ResourcePropertyFilter` (+helpers) — ต้องมี peer `@tanstack/react-table` (optional; แอปที่ไม่ใช้ตารางไม่ต้องติดตั้ง); ลากเรียงแถวใช้ `@dnd-kit/*` เป็น dependency ของแพ็กเกจ
10
+ - `useTableSearchParams` / `TABLE_PARAM` (state ตารางใน URL) ที่ `@suphark/ui/next`
11
+ - tests: data-table, data-table-faceted-filter, resource-property-filter (รวม 34)
12
+ - exports `./package.json`, `./components/data-table`
13
+ - showcase: การ์ด DataTable จริงในแท็บ Data Display; code recipe ใช้ import path ของแพ็กเกจ
14
+ - `apps/showcase/vercel.json` + `docs/ops/deploy-vercel.md`
15
+
5
16
  ## [0.1.1] — 2026-09-18
6
17
 
7
18
  ### Added
package/README.md CHANGED
@@ -10,7 +10,7 @@ Design system กลางของ Suphark apps (Pi Auth, DueBell, Pi PDF, supp
10
10
  pnpm add @suphark/ui @base-ui/react lucide-react react-hook-form
11
11
  ```
12
12
 
13
- peer ที่ต้องมีอยู่แล้ว: `react`/`react-dom` ≥19, `tailwindcss` ^4, `next` ≥15 (เฉพาะ `@suphark/ui/next`), `recharts` (เฉพาะ `components/ui/chart`)
13
+ peer ที่ต้องมีอยู่แล้ว: `react`/`react-dom` ≥19, `tailwindcss` ^4, `next` ≥15 (เฉพาะ `@suphark/ui/next`), `@tanstack/react-table` (เฉพาะ `@suphark/ui/data-table`), `recharts` (เฉพาะ `components/ui/chart`)
14
14
 
15
15
  ### 1. next.config.ts
16
16
 
@@ -58,6 +58,9 @@ import { Button, Card, FormFieldInput, StatusBadge, ThemeToggle, cn } from "@sup
58
58
  // ของที่ใช้ next/navigation หรือ next/link
59
59
  import { FormDialog, RowActions, LinkButton, RefreshButton, PageShell } from "@suphark/ui/next";
60
60
 
61
+ // ตารางข้อมูล (ต้องติดตั้ง @tanstack/react-table เอง)
62
+ import { DataTable, DataTableColumnHeader, useDataTable } from "@suphark/ui/data-table";
63
+
61
64
  // import ตรงรายไฟล์ก็ได้ (เช่น chart ที่ต้องมี recharts)
62
65
  import { ChartContainer } from "@suphark/ui/components/ui/chart";
63
66
  ```
@@ -68,6 +71,7 @@ import { ChartContainer } from "@suphark/ui/components/ui/chart";
68
71
  |---|---|---|
69
72
  | `src/components/ui/*` | shadcn/ui ต้นฉบับ 59 ตัว (+ `__tests__`) | **ห้ามแก้มือ** — เพิ่ม/อัปเดตด้วย `pnpm dlx shadcn@latest add <name>` ใน `packages/ui` (`components.json` ตั้ง alias ไว้แล้ว) |
70
73
  | `src/components/shared/*` | ของที่เราประกอบเอง: ปุ่มมาตรฐาน (`button/`), form-field บน react-hook-form (`form/`), layout, theme, combobox, และชุดจาก Pi Auth (FormDialog, NameIdFields, StatusBadge, RowMenu, …) | แก้ได้ แต่คง props เดิมให้มากที่สุด (minor = เพิ่ม, major = เปลี่ยน) |
74
+ | `src/components/data-table/*` | DataTable บน tanstack (toolbar, faceted filter, pagination, view options, row actions, ลากเรียงแถว) | entry `@suphark/ui/data-table` |
71
75
  | `src/components/design-system/*` | หน้า showcase (render ใน `apps/showcase`) | ไม่อยู่ใน barrel |
72
76
  | `src/lib/*`, `src/hooks/*` | `cn`, formatter วันที่ไทย, สีสถานะ, permission context, `useIsMobile`, `useCopyToClipboard` | |
73
77
  | `src/styles.css` | token (slate + success/warning/sidebar/chart), radius, ฟอนต์, scrollbar, dark variant | |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suphark/ui",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Design system กลางของ Suphark apps: shadcn/ui (base-nova, Base UI, Tailwind v4) + shared components + token/ฟอนต์ Noto Sans Thai Looped — ส่งเป็น source ให้แอป Next transpile เอง",
@@ -38,14 +38,20 @@
38
38
  "exports": {
39
39
  ".": "./src/index.ts",
40
40
  "./next": "./src/next.ts",
41
+ "./data-table": "./src/data-table.ts",
41
42
  "./styles.css": "./src/styles.css",
42
43
  "./components/shared/button": "./src/components/shared/button/index.ts",
43
44
  "./components/shared/theme": "./src/components/shared/theme/index.ts",
45
+ "./components/data-table": "./src/components/data-table/index.ts",
44
46
  "./components/*": "./src/components/*.tsx",
45
47
  "./lib/*": "./src/lib/*.ts",
46
- "./hooks/*": "./src/hooks/*.ts"
48
+ "./hooks/*": "./src/hooks/*.ts",
49
+ "./package.json": "./package.json"
47
50
  },
48
51
  "dependencies": {
52
+ "@dnd-kit/core": "^6.3.1",
53
+ "@dnd-kit/sortable": "^10.0.0",
54
+ "@dnd-kit/utilities": "^3.2.2",
49
55
  "@fontsource/noto-sans-thai-looped": "^5.3.0",
50
56
  "class-variance-authority": "^0.7.1",
51
57
  "clsx": "^2.1.1",
@@ -64,6 +70,7 @@
64
70
  },
65
71
  "peerDependencies": {
66
72
  "@base-ui/react": ">=1.7.0",
73
+ "@tanstack/react-table": ">=8.20.0",
67
74
  "lucide-react": ">=1.30.0",
68
75
  "next": ">=15",
69
76
  "react": ">=19",
@@ -78,10 +85,14 @@
78
85
  },
79
86
  "recharts": {
80
87
  "optional": true
88
+ },
89
+ "@tanstack/react-table": {
90
+ "optional": true
81
91
  }
82
92
  },
83
93
  "devDependencies": {
84
94
  "@base-ui/react": "^1.8.0",
95
+ "@tanstack/react-table": "^8.21.3",
85
96
  "@testing-library/jest-dom": "^6.9.1",
86
97
  "@testing-library/react": "^16.3.2",
87
98
  "@testing-library/user-event": "^14.6.1",
@@ -0,0 +1,71 @@
1
+ "use client";
2
+
3
+ import { Button } from "@suphark/ui/components/ui/button";
4
+ import {
5
+ DropdownMenu,
6
+ DropdownMenuContent,
7
+ DropdownMenuItem,
8
+ DropdownMenuSeparator,
9
+ DropdownMenuTrigger,
10
+ } from "@suphark/ui/components/ui/dropdown-menu";
11
+ import { cn } from "@suphark/ui/lib/utils";
12
+ import type { Column } from "@tanstack/react-table";
13
+ import { ArrowDown, ArrowUp, ChevronsUpDown, EyeOff, Filter } from "lucide-react";
14
+
15
+ interface DataTableColumnHeaderProps<TData, TValue> extends React.HTMLAttributes<HTMLDivElement> {
16
+ column: Column<TData, TValue>;
17
+ title: string;
18
+ }
19
+
20
+ export function DataTableColumnHeader<TData, TValue>({
21
+ column,
22
+ title,
23
+ className,
24
+ }: DataTableColumnHeaderProps<TData, TValue>) {
25
+ if (!column.getCanSort()) {
26
+ return <div className={cn(className)}>{title}</div>;
27
+ }
28
+
29
+ return (
30
+ <div className={cn("flex items-center gap-2", className)}>
31
+ <DropdownMenu>
32
+ <DropdownMenuTrigger
33
+ render={
34
+ <Button variant="ghost" size="sm" className="-ml-3 h-8 data-[state=open]:bg-accent" />
35
+ }
36
+ >
37
+ <span>{title}</span>
38
+ {column.getIsSorted() === "desc" ? (
39
+ <ArrowDown />
40
+ ) : column.getIsSorted() === "asc" ? (
41
+ <ArrowUp />
42
+ ) : (
43
+ <ChevronsUpDown />
44
+ )}
45
+ {column.getIsFiltered() && (
46
+ <Filter className="ml-1 size-3 fill-primary/10 text-primary" />
47
+ )}
48
+ </DropdownMenuTrigger>
49
+ <DropdownMenuContent align="start">
50
+ <DropdownMenuItem onClick={() => column.toggleSorting(false)}>
51
+ <ArrowUp />
52
+ Asc
53
+ </DropdownMenuItem>
54
+ <DropdownMenuItem onClick={() => column.toggleSorting(true)}>
55
+ <ArrowDown />
56
+ Desc
57
+ </DropdownMenuItem>
58
+ {column.getCanHide() && column.columnDef.enableHiding !== false && (
59
+ <>
60
+ <DropdownMenuSeparator />
61
+ <DropdownMenuItem onClick={() => column.toggleVisibility(false)}>
62
+ <EyeOff />
63
+ Hide
64
+ </DropdownMenuItem>
65
+ </>
66
+ )}
67
+ </DropdownMenuContent>
68
+ </DropdownMenu>
69
+ </div>
70
+ );
71
+ }
@@ -0,0 +1,25 @@
1
+ "use client";
2
+
3
+ import { DateRangePicker } from "@suphark/ui/components/shared/date/date-range-picker";
4
+ import type { Column } from "@tanstack/react-table";
5
+ import type { DateRange } from "react-day-picker";
6
+
7
+ interface DataTableDateRangeFilterProps<TData> {
8
+ column?: Column<TData, unknown>;
9
+ title?: string;
10
+ }
11
+
12
+ export function DataTableDateRangeFilter<TData>({
13
+ column,
14
+ title,
15
+ }: DataTableDateRangeFilterProps<TData>) {
16
+ const filterValue = column?.getFilterValue() as DateRange | undefined;
17
+
18
+ return (
19
+ <DateRangePicker
20
+ value={filterValue}
21
+ onValueChange={(range) => column?.setFilterValue(range)}
22
+ title={title}
23
+ />
24
+ );
25
+ }
@@ -0,0 +1,181 @@
1
+ "use client";
2
+
3
+ import { Badge } from "@suphark/ui/components/ui/badge";
4
+ import { Button } from "@suphark/ui/components/ui/button";
5
+ import {
6
+ Command,
7
+ CommandEmpty,
8
+ CommandGroup,
9
+ CommandInput,
10
+ CommandItem,
11
+ CommandList,
12
+ CommandSeparator,
13
+ } from "@suphark/ui/components/ui/command";
14
+ import { Popover, PopoverContent, PopoverTrigger } from "@suphark/ui/components/ui/popover";
15
+ import { Separator } from "@suphark/ui/components/ui/separator";
16
+ import { cn } from "@suphark/ui/lib/utils";
17
+ import type { Column } from "@tanstack/react-table";
18
+ import { Check, CirclePlus } from "lucide-react";
19
+ import * as React from "react";
20
+
21
+ interface DataTableFacetedFilterProps<TData, TValue> {
22
+ column?: Column<TData, TValue>;
23
+ title?: string;
24
+ options: {
25
+ label: string;
26
+ value: string | number | boolean;
27
+ icon?: React.ComponentType<{ className?: string }>;
28
+ }[];
29
+ compact?: boolean;
30
+ fullWidth?: boolean;
31
+ }
32
+
33
+ export function DataTableFacetedFilter<TData, TValue>({
34
+ column,
35
+ title,
36
+ options,
37
+ compact = false,
38
+ fullWidth = false,
39
+ }: DataTableFacetedFilterProps<TData, TValue>) {
40
+ const facets = column?.getFacetedUniqueValues();
41
+ const filterValue = column?.getFilterValue();
42
+ const selectedValues = new Set(Array.isArray(filterValue) ? filterValue.map(String) : []);
43
+
44
+ const areAllSelected = options.length > 0 && selectedValues.size === options.length;
45
+
46
+ const sortedOptions = React.useMemo(() => {
47
+ return [...options].sort((a, b) => {
48
+ const aSelected = selectedValues.has(String(a.value));
49
+ const bSelected = selectedValues.has(String(b.value));
50
+ if (aSelected && !bSelected) return -1;
51
+ if (!aSelected && bSelected) return 1;
52
+ return 0;
53
+ });
54
+ }, [options, selectedValues]);
55
+
56
+ return (
57
+ <Popover>
58
+ <PopoverTrigger
59
+ render={
60
+ <Button
61
+ variant="outline"
62
+ size="sm"
63
+ className={cn(
64
+ "h-8 border-dashed",
65
+ compact && "h-7 px-2 text-xs",
66
+ fullWidth && "w-full justify-start",
67
+ )}
68
+ />
69
+ }
70
+ >
71
+ <CirclePlus />
72
+ {title}
73
+ {selectedValues?.size > 0 && (
74
+ <>
75
+ <Separator orientation="vertical" className="mx-2 h-4" />
76
+ <Badge
77
+ variant="secondary"
78
+ className={cn("rounded-sm px-1 font-normal", !compact && "lg:hidden")}
79
+ >
80
+ {selectedValues.size}
81
+ </Badge>
82
+ <div className={cn("hidden gap-1 lg:flex", compact && "lg:hidden")}>
83
+ {selectedValues.size > 2 ? (
84
+ <Badge variant="secondary" className="rounded-sm px-1 font-normal">
85
+ เลือก {selectedValues.size} รายการ
86
+ </Badge>
87
+ ) : (
88
+ options.reduce((acc: React.ReactNode[], option) => {
89
+ if (selectedValues.has(String(option.value))) {
90
+ acc.push(
91
+ <Badge
92
+ variant="secondary"
93
+ key={String(option.value)}
94
+ className="rounded-sm px-1 font-normal"
95
+ >
96
+ {option.label}
97
+ </Badge>,
98
+ );
99
+ }
100
+ return acc;
101
+ }, [])
102
+ )}
103
+ </div>
104
+ </>
105
+ )}
106
+ </PopoverTrigger>
107
+ <PopoverContent className={cn("w-[200px] p-0", compact && "w-44")} align="start">
108
+ <Command>
109
+ {!compact ? <CommandInput placeholder={title} /> : null}
110
+ <CommandList>
111
+ <CommandEmpty>ไม่พบข้อมูล</CommandEmpty>
112
+ <CommandGroup>
113
+ {sortedOptions.map((option) => {
114
+ const valueAsString = String(option.value);
115
+ const isSelected = selectedValues.has(valueAsString);
116
+ return (
117
+ <CommandItem
118
+ key={valueAsString}
119
+ onSelect={() => {
120
+ if (isSelected) {
121
+ selectedValues.delete(valueAsString);
122
+ } else {
123
+ selectedValues.add(valueAsString);
124
+ }
125
+ const filterValues = Array.from(selectedValues);
126
+ column?.setFilterValue(filterValues.length ? filterValues : undefined);
127
+ }}
128
+ >
129
+ <div
130
+ className={cn(
131
+ "flex size-4 items-center justify-center rounded-[4px] border",
132
+ isSelected
133
+ ? "border-primary bg-primary text-primary-foreground"
134
+ : "border-input [&_svg]:invisible",
135
+ )}
136
+ >
137
+ <Check className="size-3.5 text-primary-foreground" />
138
+ </div>
139
+ {option.icon && <option.icon className="size-4 text-muted-foreground" />}
140
+ <span>{option.label}</span>
141
+ {facets?.get(valueAsString) && (
142
+ <span className="ml-auto flex size-4 items-center justify-center font-mono text-muted-foreground text-xs">
143
+ {facets.get(valueAsString)}
144
+ </span>
145
+ )}
146
+ </CommandItem>
147
+ );
148
+ })}
149
+ </CommandGroup>
150
+ {!compact && (selectedValues.size > 0 || !areAllSelected) && (
151
+ <>
152
+ <CommandSeparator />
153
+ <CommandGroup>
154
+ {areAllSelected ? (
155
+ <CommandItem
156
+ onSelect={() => column?.setFilterValue(undefined)}
157
+ className="justify-center text-center"
158
+ >
159
+ ล้างตัวกรอง
160
+ </CommandItem>
161
+ ) : (
162
+ <CommandItem
163
+ key="select-all"
164
+ onSelect={() => {
165
+ const allValues = options.map((option) => String(option.value));
166
+ column?.setFilterValue(allValues);
167
+ }}
168
+ className="justify-center text-center"
169
+ >
170
+ เลือกทั้งหมด
171
+ </CommandItem>
172
+ )}
173
+ </CommandGroup>
174
+ </>
175
+ )}
176
+ </CommandList>
177
+ </Command>
178
+ </PopoverContent>
179
+ </Popover>
180
+ );
181
+ }
@@ -0,0 +1,114 @@
1
+ "use client";
2
+
3
+ import { Button } from "@suphark/ui/components/ui/button";
4
+ import {
5
+ Select,
6
+ SelectContent,
7
+ SelectItem,
8
+ SelectTrigger,
9
+ SelectValue,
10
+ } from "@suphark/ui/components/ui/select";
11
+ import { cn } from "@suphark/ui/lib/utils";
12
+ import type { Table } from "@tanstack/react-table";
13
+ import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react";
14
+
15
+ interface DataTablePaginationProps<TData> {
16
+ table: Table<TData>;
17
+ pageSizeOptions?: number[];
18
+ onPageSizeChange?: (pageSize: number) => void;
19
+ }
20
+
21
+ export function DataTablePagination<TData>({
22
+ table,
23
+ pageSizeOptions = [10, 20, 30, 40, 50],
24
+ onPageSizeChange,
25
+ }: DataTablePaginationProps<TData>) {
26
+ const isRowSelectionEnabled = table.options.enableRowSelection !== false;
27
+
28
+ return (
29
+ <div
30
+ className={cn(
31
+ "flex flex-col items-center justify-between gap-4 px-2 sm:flex-row sm:gap-0",
32
+ !isRowSelectionEnabled && "sm:justify-end",
33
+ )}
34
+ >
35
+ {isRowSelectionEnabled && (
36
+ <div className="flex-1 text-muted-foreground text-sm max-sm:text-center">
37
+ {table.getFilteredSelectedRowModel().rows.length} of{" "}
38
+ {table.getFilteredRowModel().rows.length} row(s) selected.
39
+ </div>
40
+ )}
41
+ <div className="flex flex-wrap items-center justify-center gap-4 sm:gap-6 lg:gap-8">
42
+ <div className="flex items-center gap-2">
43
+ <p className="font-medium text-sm max-sm:hidden">Rows per page</p>
44
+ <Select
45
+ value={`${table.getState().pagination.pageSize}`}
46
+ onValueChange={(value) => {
47
+ const newSize = Number(value);
48
+ table.setPageSize(newSize);
49
+ if (onPageSizeChange) {
50
+ onPageSizeChange(newSize);
51
+ }
52
+ }}
53
+ >
54
+ <SelectTrigger className="h-8 w-[70px]">
55
+ <SelectValue placeholder={table.getState().pagination.pageSize} />
56
+ </SelectTrigger>
57
+ <SelectContent side="top">
58
+ {pageSizeOptions.map((pageSize) => (
59
+ <SelectItem key={pageSize} value={`${pageSize}`}>
60
+ {pageSize}
61
+ </SelectItem>
62
+ ))}
63
+ </SelectContent>
64
+ </Select>
65
+ </div>
66
+ <div className="flex w-[100px] items-center justify-center font-medium text-sm">
67
+ Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
68
+ </div>
69
+ <div className="flex items-center gap-2">
70
+ <Button
71
+ variant="outline"
72
+ size="icon"
73
+ className="hidden size-8 lg:flex"
74
+ onClick={() => table.setPageIndex(0)}
75
+ disabled={!table.getCanPreviousPage()}
76
+ >
77
+ <span className="sr-only">Go to first page</span>
78
+ <ChevronsLeft />
79
+ </Button>
80
+ <Button
81
+ variant="outline"
82
+ size="icon"
83
+ className="size-8"
84
+ onClick={() => table.previousPage()}
85
+ disabled={!table.getCanPreviousPage()}
86
+ >
87
+ <span className="sr-only">Go to previous page</span>
88
+ <ChevronLeft />
89
+ </Button>
90
+ <Button
91
+ variant="outline"
92
+ size="icon"
93
+ className="size-8"
94
+ onClick={() => table.nextPage()}
95
+ disabled={!table.getCanNextPage()}
96
+ >
97
+ <span className="sr-only">Go to next page</span>
98
+ <ChevronRight />
99
+ </Button>
100
+ <Button
101
+ variant="outline"
102
+ size="icon"
103
+ className="hidden size-8 lg:flex"
104
+ onClick={() => table.setPageIndex(table.getPageCount() - 1)}
105
+ disabled={!table.getCanNextPage()}
106
+ >
107
+ <span className="sr-only">Go to last page</span>
108
+ <ChevronsRight />
109
+ </Button>
110
+ </div>
111
+ </div>
112
+ </div>
113
+ );
114
+ }
@@ -0,0 +1,166 @@
1
+ "use client";
2
+
3
+ import { DeleteButton } from "@suphark/ui/components/shared/button/delete-button";
4
+ import {
5
+ AlertDialog,
6
+ AlertDialogCancel,
7
+ AlertDialogContent,
8
+ AlertDialogDescription,
9
+ AlertDialogFooter,
10
+ AlertDialogHeader,
11
+ AlertDialogTitle,
12
+ } from "@suphark/ui/components/ui/alert-dialog";
13
+ import { Button } from "@suphark/ui/components/ui/button";
14
+ import {
15
+ DropdownMenu,
16
+ DropdownMenuContent,
17
+ DropdownMenuGroup,
18
+ DropdownMenuItem,
19
+ DropdownMenuSeparator,
20
+ DropdownMenuTrigger,
21
+ } from "@suphark/ui/components/ui/dropdown-menu";
22
+ import type { Row } from "@tanstack/react-table";
23
+ import { Ellipsis, Pen, Trash } from "lucide-react";
24
+ import { useState, useTransition } from "react";
25
+ import { toast } from "sonner";
26
+
27
+ interface ActionState<T> {
28
+ error?: string;
29
+ data?: T;
30
+ success?: string;
31
+ }
32
+
33
+ interface DataTableRowActionsProps<TData> {
34
+ row: Row<TData>;
35
+ /**
36
+ * Optional custom menu items to display before the standard Edit/Delete actions.
37
+ */
38
+ children?: React.ReactNode;
39
+ /**
40
+ * Optional component to render for the edit dialog.
41
+ * If not provided, the "Edit" action will not be shown.
42
+ */
43
+ EditDialog?: React.ComponentType<{
44
+ initialData?: TData;
45
+ open?: boolean;
46
+ onOpenChange?: (open: boolean) => void;
47
+ children?: React.ReactNode;
48
+ }>;
49
+ /**
50
+ * Optional server action to call for deletion.
51
+ * If not provided, the "Delete" action will not be shown.
52
+ */
53
+ deleteAction?: (id: string) => Promise<ActionState<unknown>>;
54
+ /**
55
+ * The name of the resource to display in toast messages (e.g. "User Status").
56
+ */
57
+ resourceName: string;
58
+ /**
59
+ * The key to access the name/title of the item for display. Defaults to "name".
60
+ */
61
+ nameKey?: keyof TData;
62
+ /**
63
+ * The key to access the unique ID of the item. Defaults to "id".
64
+ */
65
+ idKey?: keyof TData;
66
+ }
67
+
68
+ export function DataTableRowActions<TData>({
69
+ row,
70
+ children,
71
+ EditDialog,
72
+ deleteAction,
73
+ resourceName,
74
+ nameKey = "name" as keyof TData,
75
+ idKey = "id" as keyof TData,
76
+ }: DataTableRowActionsProps<TData>) {
77
+ const item = row.original;
78
+ const itemRecord = item as Record<string, unknown>;
79
+ const itemName = String(
80
+ itemRecord[nameKey as string] ?? itemRecord.nameThai ?? itemRecord.name ?? "",
81
+ );
82
+ const [isPending, startTransition] = useTransition();
83
+ const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
84
+ const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
85
+
86
+ const handleDelete = () => {
87
+ if (!deleteAction) return;
88
+
89
+ startTransition(() => {
90
+ const promise = deleteAction(String(itemRecord[idKey as string])).then((result) => {
91
+ if (result.error) throw new Error(result.error);
92
+ setIsDeleteDialogOpen(false);
93
+ return result;
94
+ });
95
+
96
+ toast.promise(promise, {
97
+ loading: `Deleting ${resourceName.toLowerCase()} "${itemName}"...`,
98
+ success: `${resourceName} "${itemName}" deleted successfully.`,
99
+ error: (err: Error) => err.message,
100
+ });
101
+ });
102
+ };
103
+
104
+ return (
105
+ <div className="flex justify-end">
106
+ <DropdownMenu>
107
+ <DropdownMenuTrigger
108
+ render={
109
+ <Button variant="ghost" className="flex h-8 w-8 p-0 data-[state=open]:bg-muted" />
110
+ }
111
+ >
112
+ <Ellipsis className="h-4 w-4" />
113
+ <span className="sr-only">Open menu</span>
114
+ </DropdownMenuTrigger>
115
+ <DropdownMenuContent align="end" className="w-[160px]">
116
+ {children}
117
+ {children && (EditDialog || deleteAction) && <DropdownMenuSeparator />}
118
+
119
+ <DropdownMenuGroup>
120
+ {EditDialog && (
121
+ <DropdownMenuItem onClick={() => setIsEditDialogOpen(true)}>
122
+ <Pen className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
123
+ Edit
124
+ </DropdownMenuItem>
125
+ )}
126
+
127
+ {EditDialog && deleteAction && <DropdownMenuSeparator />}
128
+
129
+ {deleteAction && (
130
+ <DropdownMenuItem
131
+ className="text-destructive focus:text-destructive"
132
+ onClick={() => setIsDeleteDialogOpen(true)}
133
+ >
134
+ <Trash className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
135
+ Delete
136
+ </DropdownMenuItem>
137
+ )}
138
+ </DropdownMenuGroup>
139
+ </DropdownMenuContent>
140
+ </DropdownMenu>
141
+
142
+ {EditDialog && (
143
+ <EditDialog initialData={item} open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen} />
144
+ )}
145
+
146
+ {deleteAction && (
147
+ <AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
148
+ <AlertDialogContent>
149
+ <AlertDialogHeader>
150
+ <AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
151
+ <AlertDialogDescription>
152
+ This action cannot be undone. This will permanently delete the{" "}
153
+ {resourceName.toLowerCase()} &ldquo;{itemName}&rdquo; and remove it from our
154
+ servers.
155
+ </AlertDialogDescription>
156
+ </AlertDialogHeader>
157
+ <AlertDialogFooter>
158
+ <AlertDialogCancel>Cancel</AlertDialogCancel>
159
+ <DeleteButton onClick={handleDelete} isLoading={isPending} />
160
+ </AlertDialogFooter>
161
+ </AlertDialogContent>
162
+ </AlertDialog>
163
+ )}
164
+ </div>
165
+ );
166
+ }