@suphark/ui 0.1.1 → 0.3.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 +22 -0
- package/README.md +45 -3
- package/package.json +14 -2
- package/src/components/app-shell/app-brand.tsx +81 -0
- package/src/components/app-shell/app-nav.tsx +260 -0
- package/src/components/app-shell/app-shell.tsx +270 -0
- package/src/components/app-shell/header-breadcrumbs.tsx +78 -0
- package/src/components/app-shell/index.ts +14 -0
- package/src/components/app-shell/nav-link.tsx +35 -0
- package/src/components/app-shell/types.ts +92 -0
- package/src/components/app-shell/user-menu.tsx +133 -0
- package/src/components/data-table/data-table-column-header.tsx +71 -0
- package/src/components/data-table/data-table-date-range-filter.tsx +25 -0
- package/src/components/data-table/data-table-faceted-filter.tsx +181 -0
- package/src/components/data-table/data-table-pagination.tsx +114 -0
- package/src/components/data-table/data-table-row-actions.tsx +166 -0
- package/src/components/data-table/data-table-toolbar.tsx +207 -0
- package/src/components/data-table/data-table-view-option.tsx +59 -0
- package/src/components/data-table/data-table.tsx +399 -0
- package/src/components/data-table/index.ts +9 -0
- package/src/components/data-table/types.ts +106 -0
- package/src/components/data-table/use-data-table.ts +229 -0
- package/src/components/design-system/sections/showcase-data-display.tsx +71 -0
- package/src/components/design-system/sections/showcase-guidelines-recipes.tsx +8 -8
- package/src/components/shared/button/delete-many-button.tsx +85 -0
- package/src/components/shared/resource-property-filter.tsx +399 -0
- package/src/data-table.ts +21 -0
- package/src/hooks/use-table-search-params.ts +147 -0
- package/src/index.ts +2 -1
- package/src/next.ts +8 -0
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Badge } from "@suphark/ui/components/ui/badge";
|
|
4
|
+
import { Button } from "@suphark/ui/components/ui/button";
|
|
5
|
+
import { Checkbox } from "@suphark/ui/components/ui/checkbox";
|
|
6
|
+
import { Empty, EmptyHeader, EmptyTitle } from "@suphark/ui/components/ui/empty";
|
|
7
|
+
import { Field, FieldGroup, FieldLabel } from "@suphark/ui/components/ui/field";
|
|
8
|
+
import { Input } from "@suphark/ui/components/ui/input";
|
|
9
|
+
import {
|
|
10
|
+
InputGroup,
|
|
11
|
+
InputGroupAddon,
|
|
12
|
+
InputGroupButton,
|
|
13
|
+
InputGroupInput,
|
|
14
|
+
} from "@suphark/ui/components/ui/input-group";
|
|
15
|
+
import {
|
|
16
|
+
Popover,
|
|
17
|
+
PopoverContent,
|
|
18
|
+
PopoverDescription,
|
|
19
|
+
PopoverHeader,
|
|
20
|
+
PopoverTitle,
|
|
21
|
+
PopoverTrigger,
|
|
22
|
+
} from "@suphark/ui/components/ui/popover";
|
|
23
|
+
import { ScrollArea } from "@suphark/ui/components/ui/scroll-area";
|
|
24
|
+
import {
|
|
25
|
+
Select,
|
|
26
|
+
SelectContent,
|
|
27
|
+
SelectGroup,
|
|
28
|
+
SelectItem,
|
|
29
|
+
SelectTrigger,
|
|
30
|
+
SelectValue,
|
|
31
|
+
} from "@suphark/ui/components/ui/select";
|
|
32
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
33
|
+
import { ListFilter, RotateCcw, Search, X } from "lucide-react";
|
|
34
|
+
import { useDeferredValue, useId, useState } from "react";
|
|
35
|
+
|
|
36
|
+
export type ResourcePropertyFilterValue =
|
|
37
|
+
| { dataType: "DROPDOWN"; values: string[] }
|
|
38
|
+
| { dataType: "NUMBER"; min: string; max: string; unitId: string | null }
|
|
39
|
+
| { dataType: "TEXT"; query: string };
|
|
40
|
+
|
|
41
|
+
export type ResourcePropertyFilterState = Record<string, ResourcePropertyFilterValue>;
|
|
42
|
+
|
|
43
|
+
export interface ResourcePropertyFilterDefinition {
|
|
44
|
+
propertyTemplateId: string;
|
|
45
|
+
propertyName: string;
|
|
46
|
+
dataType: "TEXT" | "NUMBER" | "DROPDOWN" | "DATABASE";
|
|
47
|
+
workItemName: string;
|
|
48
|
+
options: Array<{ value: string; label: string }>;
|
|
49
|
+
units: Array<{ id: string; label: string; factor: number }>;
|
|
50
|
+
defaultUnitId: string | null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface ResourcePropertyFilterProps {
|
|
54
|
+
definitions: ResourcePropertyFilterDefinition[];
|
|
55
|
+
filters: ResourcePropertyFilterState;
|
|
56
|
+
onChange: (filters: ResourcePropertyFilterState) => void;
|
|
57
|
+
showWorkItemName?: boolean;
|
|
58
|
+
triggerLabel?: string;
|
|
59
|
+
triggerClassName?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function isResourcePropertyFilterActive(
|
|
63
|
+
filter: ResourcePropertyFilterValue | undefined,
|
|
64
|
+
): boolean {
|
|
65
|
+
if (!filter) return false;
|
|
66
|
+
if (filter.dataType === "DROPDOWN") return filter.values.length > 0;
|
|
67
|
+
if (filter.dataType === "TEXT") return filter.query.trim().length > 0;
|
|
68
|
+
return isFiniteInput(filter.min) || isFiniteInput(filter.max);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function countActiveResourcePropertyFilters(filters: ResourcePropertyFilterState): number {
|
|
72
|
+
return Object.values(filters).filter(isResourcePropertyFilterActive).length;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function filterResourcePropertyDefinitions(
|
|
76
|
+
definitions: ResourcePropertyFilterDefinition[],
|
|
77
|
+
search: string,
|
|
78
|
+
): ResourcePropertyFilterDefinition[] {
|
|
79
|
+
const normalizedSearch = search.trim().toLocaleLowerCase("th");
|
|
80
|
+
if (!normalizedSearch) return definitions;
|
|
81
|
+
|
|
82
|
+
return definitions.filter((definition) =>
|
|
83
|
+
`${definition.propertyName} ${definition.workItemName}`
|
|
84
|
+
.toLocaleLowerCase("th")
|
|
85
|
+
.includes(normalizedSearch),
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function ResourcePropertyFilter({
|
|
90
|
+
definitions,
|
|
91
|
+
filters,
|
|
92
|
+
onChange,
|
|
93
|
+
showWorkItemName = false,
|
|
94
|
+
triggerLabel = "คุณสมบัติ Resource",
|
|
95
|
+
triggerClassName,
|
|
96
|
+
}: ResourcePropertyFilterProps) {
|
|
97
|
+
const idPrefix = useId();
|
|
98
|
+
const [propertySearch, setPropertySearch] = useState("");
|
|
99
|
+
const deferredPropertySearch = useDeferredValue(propertySearch);
|
|
100
|
+
const activeCount = countActiveResourcePropertyFilters(filters);
|
|
101
|
+
const visibleDefinitions = filterResourcePropertyDefinitions(definitions, deferredPropertySearch);
|
|
102
|
+
|
|
103
|
+
const updateFilter = (propertyTemplateId: string, filter: ResourcePropertyFilterValue) => {
|
|
104
|
+
onChange({ ...filters, [propertyTemplateId]: filter });
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const clearFilter = (propertyTemplateId: string) => {
|
|
108
|
+
const nextFilters = { ...filters };
|
|
109
|
+
delete nextFilters[propertyTemplateId];
|
|
110
|
+
onChange(nextFilters);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<div className="flex flex-col gap-2">
|
|
115
|
+
<Popover>
|
|
116
|
+
<PopoverTrigger
|
|
117
|
+
render={
|
|
118
|
+
<Button
|
|
119
|
+
type="button"
|
|
120
|
+
variant="outline"
|
|
121
|
+
size="sm"
|
|
122
|
+
className={cn("w-full justify-start border-dashed sm:w-auto", triggerClassName)}
|
|
123
|
+
disabled={definitions.length === 0}
|
|
124
|
+
/>
|
|
125
|
+
}
|
|
126
|
+
>
|
|
127
|
+
<ListFilter data-icon="inline-start" />
|
|
128
|
+
{triggerLabel}
|
|
129
|
+
{activeCount > 0 ? (
|
|
130
|
+
<Badge variant="secondary" className="ml-auto sm:ml-1">
|
|
131
|
+
{activeCount}
|
|
132
|
+
</Badge>
|
|
133
|
+
) : null}
|
|
134
|
+
</PopoverTrigger>
|
|
135
|
+
<PopoverContent align="start" className="w-[min(24rem,calc(100vw-2rem))] p-0">
|
|
136
|
+
<PopoverHeader className="border-b p-4">
|
|
137
|
+
<div className="flex items-start justify-between gap-3">
|
|
138
|
+
<div className="flex flex-col gap-1">
|
|
139
|
+
<PopoverTitle>กรองตามคุณสมบัติ</PopoverTitle>
|
|
140
|
+
<PopoverDescription>ค่าในคุณสมบัติเดียวกันเป็น OR และคนละคุณสมบัติเป็น AND</PopoverDescription>
|
|
141
|
+
</div>
|
|
142
|
+
{activeCount > 0 ? (
|
|
143
|
+
<Button type="button" variant="ghost" size="xs" onClick={() => onChange({})}>
|
|
144
|
+
<RotateCcw data-icon="inline-start" />
|
|
145
|
+
ล้าง
|
|
146
|
+
</Button>
|
|
147
|
+
) : null}
|
|
148
|
+
</div>
|
|
149
|
+
</PopoverHeader>
|
|
150
|
+
<div className="border-b p-3">
|
|
151
|
+
<InputGroup>
|
|
152
|
+
<InputGroupAddon align="inline-start">
|
|
153
|
+
<Search aria-hidden="true" />
|
|
154
|
+
</InputGroupAddon>
|
|
155
|
+
<InputGroupInput
|
|
156
|
+
value={propertySearch}
|
|
157
|
+
onChange={(event) => setPropertySearch(event.target.value)}
|
|
158
|
+
placeholder="ค้นหาชื่อ Property..."
|
|
159
|
+
aria-label="ค้นหาคุณสมบัติ"
|
|
160
|
+
/>
|
|
161
|
+
{propertySearch ? (
|
|
162
|
+
<InputGroupAddon align="inline-end">
|
|
163
|
+
<InputGroupButton
|
|
164
|
+
size="icon-xs"
|
|
165
|
+
onClick={() => setPropertySearch("")}
|
|
166
|
+
aria-label="ล้างคำค้นหา"
|
|
167
|
+
>
|
|
168
|
+
<X />
|
|
169
|
+
</InputGroupButton>
|
|
170
|
+
</InputGroupAddon>
|
|
171
|
+
) : null}
|
|
172
|
+
</InputGroup>
|
|
173
|
+
</div>
|
|
174
|
+
<ScrollArea className="h-[min(60vh,520px)]">
|
|
175
|
+
{visibleDefinitions.length > 0 ? (
|
|
176
|
+
<FieldGroup className="gap-4 p-4">
|
|
177
|
+
{visibleDefinitions.map((definition) => {
|
|
178
|
+
const filter = filters[definition.propertyTemplateId];
|
|
179
|
+
|
|
180
|
+
return (
|
|
181
|
+
<Field
|
|
182
|
+
key={definition.propertyTemplateId}
|
|
183
|
+
className="gap-2 rounded-md border p-3"
|
|
184
|
+
>
|
|
185
|
+
<div className="flex items-start justify-between gap-2">
|
|
186
|
+
<div className="flex min-w-0 flex-col gap-0.5">
|
|
187
|
+
<FieldLabel className="font-medium">{definition.propertyName}</FieldLabel>
|
|
188
|
+
{showWorkItemName ? (
|
|
189
|
+
<span className="truncate text-muted-foreground text-xs">
|
|
190
|
+
{definition.workItemName}
|
|
191
|
+
</span>
|
|
192
|
+
) : null}
|
|
193
|
+
</div>
|
|
194
|
+
{isResourcePropertyFilterActive(filter) ? (
|
|
195
|
+
<Button
|
|
196
|
+
type="button"
|
|
197
|
+
variant="ghost"
|
|
198
|
+
size="icon-xs"
|
|
199
|
+
onClick={() => clearFilter(definition.propertyTemplateId)}
|
|
200
|
+
aria-label={`ล้างตัวกรอง ${definition.propertyName}`}
|
|
201
|
+
>
|
|
202
|
+
<X />
|
|
203
|
+
</Button>
|
|
204
|
+
) : null}
|
|
205
|
+
</div>
|
|
206
|
+
|
|
207
|
+
{definition.dataType === "DROPDOWN" || definition.dataType === "DATABASE" ? (
|
|
208
|
+
<DropdownPropertyField
|
|
209
|
+
idPrefix={idPrefix}
|
|
210
|
+
definition={definition}
|
|
211
|
+
filter={filter?.dataType === "DROPDOWN" ? filter : undefined}
|
|
212
|
+
onChange={(nextFilter) =>
|
|
213
|
+
updateFilter(definition.propertyTemplateId, nextFilter)
|
|
214
|
+
}
|
|
215
|
+
/>
|
|
216
|
+
) : definition.dataType === "NUMBER" ? (
|
|
217
|
+
<NumberPropertyField
|
|
218
|
+
definition={definition}
|
|
219
|
+
filter={filter?.dataType === "NUMBER" ? filter : undefined}
|
|
220
|
+
onChange={(nextFilter) =>
|
|
221
|
+
updateFilter(definition.propertyTemplateId, nextFilter)
|
|
222
|
+
}
|
|
223
|
+
/>
|
|
224
|
+
) : (
|
|
225
|
+
<Input
|
|
226
|
+
value={filter?.dataType === "TEXT" ? filter.query : ""}
|
|
227
|
+
onChange={(event) =>
|
|
228
|
+
updateFilter(definition.propertyTemplateId, {
|
|
229
|
+
dataType: "TEXT",
|
|
230
|
+
query: event.target.value,
|
|
231
|
+
})
|
|
232
|
+
}
|
|
233
|
+
placeholder="ค้นหาข้อความ..."
|
|
234
|
+
aria-label={`กรอง ${definition.propertyName}`}
|
|
235
|
+
/>
|
|
236
|
+
)}
|
|
237
|
+
</Field>
|
|
238
|
+
);
|
|
239
|
+
})}
|
|
240
|
+
</FieldGroup>
|
|
241
|
+
) : (
|
|
242
|
+
<Empty className="min-h-40 rounded-none border-0">
|
|
243
|
+
<EmptyHeader>
|
|
244
|
+
<EmptyTitle>ไม่พบคุณสมบัติ</EmptyTitle>
|
|
245
|
+
</EmptyHeader>
|
|
246
|
+
</Empty>
|
|
247
|
+
)}
|
|
248
|
+
</ScrollArea>
|
|
249
|
+
</PopoverContent>
|
|
250
|
+
</Popover>
|
|
251
|
+
|
|
252
|
+
{activeCount > 0 ? (
|
|
253
|
+
<fieldset className="flex flex-wrap gap-1 border-0 p-0" aria-label="ตัวกรองคุณสมบัติที่ใช้งาน">
|
|
254
|
+
{definitions.map((definition) => {
|
|
255
|
+
const filter = filters[definition.propertyTemplateId];
|
|
256
|
+
if (!isResourcePropertyFilterActive(filter)) return null;
|
|
257
|
+
|
|
258
|
+
return (
|
|
259
|
+
<Badge key={definition.propertyTemplateId} variant="secondary" className="gap-1 pr-0">
|
|
260
|
+
<span className="max-w-56 truncate">
|
|
261
|
+
{definition.propertyName}: {describeFilter(definition, filter)}
|
|
262
|
+
</span>
|
|
263
|
+
<Button
|
|
264
|
+
type="button"
|
|
265
|
+
variant="ghost"
|
|
266
|
+
size="icon-xs"
|
|
267
|
+
onClick={() => clearFilter(definition.propertyTemplateId)}
|
|
268
|
+
aria-label={`ล้างตัวกรอง ${definition.propertyName}`}
|
|
269
|
+
>
|
|
270
|
+
<X />
|
|
271
|
+
</Button>
|
|
272
|
+
</Badge>
|
|
273
|
+
);
|
|
274
|
+
})}
|
|
275
|
+
</fieldset>
|
|
276
|
+
) : null}
|
|
277
|
+
</div>
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function DropdownPropertyField({
|
|
282
|
+
idPrefix,
|
|
283
|
+
definition,
|
|
284
|
+
filter,
|
|
285
|
+
onChange,
|
|
286
|
+
}: {
|
|
287
|
+
idPrefix: string;
|
|
288
|
+
definition: ResourcePropertyFilterDefinition;
|
|
289
|
+
filter?: Extract<ResourcePropertyFilterValue, { dataType: "DROPDOWN" }>;
|
|
290
|
+
onChange: (filter: Extract<ResourcePropertyFilterValue, { dataType: "DROPDOWN" }>) => void;
|
|
291
|
+
}) {
|
|
292
|
+
const selectedValues = new Set(filter?.values ?? []);
|
|
293
|
+
|
|
294
|
+
return (
|
|
295
|
+
<FieldGroup data-slot="checkbox-group" className="gap-2">
|
|
296
|
+
{definition.options.map((option) => {
|
|
297
|
+
const id = `${idPrefix}-resource-property-${definition.propertyTemplateId}-${option.value}`;
|
|
298
|
+
|
|
299
|
+
return (
|
|
300
|
+
<Field key={option.value} orientation="horizontal" className="gap-2">
|
|
301
|
+
<Checkbox
|
|
302
|
+
id={id}
|
|
303
|
+
checked={selectedValues.has(option.value)}
|
|
304
|
+
onCheckedChange={(checked) => {
|
|
305
|
+
const nextValues = new Set(selectedValues);
|
|
306
|
+
if (checked === true) nextValues.add(option.value);
|
|
307
|
+
else nextValues.delete(option.value);
|
|
308
|
+
onChange({ dataType: "DROPDOWN", values: Array.from(nextValues) });
|
|
309
|
+
}}
|
|
310
|
+
/>
|
|
311
|
+
<FieldLabel htmlFor={id} className="cursor-pointer font-normal">
|
|
312
|
+
{option.label}
|
|
313
|
+
</FieldLabel>
|
|
314
|
+
</Field>
|
|
315
|
+
);
|
|
316
|
+
})}
|
|
317
|
+
</FieldGroup>
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function NumberPropertyField({
|
|
322
|
+
definition,
|
|
323
|
+
filter,
|
|
324
|
+
onChange,
|
|
325
|
+
}: {
|
|
326
|
+
definition: ResourcePropertyFilterDefinition;
|
|
327
|
+
filter?: Extract<ResourcePropertyFilterValue, { dataType: "NUMBER" }>;
|
|
328
|
+
onChange: (filter: Extract<ResourcePropertyFilterValue, { dataType: "NUMBER" }>) => void;
|
|
329
|
+
}) {
|
|
330
|
+
const currentFilter = filter ?? {
|
|
331
|
+
dataType: "NUMBER" as const,
|
|
332
|
+
min: "",
|
|
333
|
+
max: "",
|
|
334
|
+
unitId: definition.defaultUnitId,
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
return (
|
|
338
|
+
<div className="grid grid-cols-2 gap-2">
|
|
339
|
+
<Input
|
|
340
|
+
type="number"
|
|
341
|
+
inputMode="decimal"
|
|
342
|
+
step="any"
|
|
343
|
+
value={currentFilter.min}
|
|
344
|
+
onChange={(event) => onChange({ ...currentFilter, min: event.target.value })}
|
|
345
|
+
placeholder="ต่ำสุด"
|
|
346
|
+
aria-label={`${definition.propertyName} ต่ำสุด`}
|
|
347
|
+
/>
|
|
348
|
+
<Input
|
|
349
|
+
type="number"
|
|
350
|
+
inputMode="decimal"
|
|
351
|
+
step="any"
|
|
352
|
+
value={currentFilter.max}
|
|
353
|
+
onChange={(event) => onChange({ ...currentFilter, max: event.target.value })}
|
|
354
|
+
placeholder="สูงสุด"
|
|
355
|
+
aria-label={`${definition.propertyName} สูงสุด`}
|
|
356
|
+
/>
|
|
357
|
+
{definition.units.length > 0 ? (
|
|
358
|
+
<Select
|
|
359
|
+
value={currentFilter.unitId ?? undefined}
|
|
360
|
+
onValueChange={(unitId) => onChange({ ...currentFilter, unitId })}
|
|
361
|
+
>
|
|
362
|
+
<SelectTrigger className="col-span-2 w-full">
|
|
363
|
+
<SelectValue placeholder="เลือกหน่วย" />
|
|
364
|
+
</SelectTrigger>
|
|
365
|
+
<SelectContent>
|
|
366
|
+
<SelectGroup>
|
|
367
|
+
{definition.units.map((unit) => (
|
|
368
|
+
<SelectItem key={unit.id} value={unit.id}>
|
|
369
|
+
{unit.label}
|
|
370
|
+
</SelectItem>
|
|
371
|
+
))}
|
|
372
|
+
</SelectGroup>
|
|
373
|
+
</SelectContent>
|
|
374
|
+
</Select>
|
|
375
|
+
) : null}
|
|
376
|
+
</div>
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function describeFilter(
|
|
381
|
+
definition: ResourcePropertyFilterDefinition,
|
|
382
|
+
filter: ResourcePropertyFilterValue | undefined,
|
|
383
|
+
): string {
|
|
384
|
+
if (!filter) return "";
|
|
385
|
+
if (filter.dataType === "TEXT") return filter.query.trim();
|
|
386
|
+
if (filter.dataType === "DROPDOWN") {
|
|
387
|
+
const labels = new Map(definition.options.map((option) => [option.value, option.label]));
|
|
388
|
+
return filter.values.map((value) => labels.get(value) ?? value).join(" หรือ ");
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const unitLabel = definition.units.find((unit) => unit.id === filter.unitId)?.label ?? "";
|
|
392
|
+
if (filter.min && filter.max) return `${filter.min}–${filter.max} ${unitLabel}`.trim();
|
|
393
|
+
if (filter.min) return `≥ ${filter.min} ${unitLabel}`.trim();
|
|
394
|
+
return `≤ ${filter.max} ${unitLabel}`.trim();
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function isFiniteInput(value: string): boolean {
|
|
398
|
+
return value.trim() !== "" && Number.isFinite(Number(value));
|
|
399
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @suphark/ui/data-table — ตารางข้อมูลบน @tanstack/react-table (ยกจาก supplychain `components/data-table`)
|
|
3
|
+
* แยก entry เพราะต้องมี peer `@tanstack/react-table` — แอปที่ไม่ใช้ตารางไม่ต้องติดตั้ง
|
|
4
|
+
*
|
|
5
|
+
* ```ts
|
|
6
|
+
* import { DataTable, DataTableColumnHeader, useDataTable } from "@suphark/ui/data-table";
|
|
7
|
+
* ```
|
|
8
|
+
* state ของตารางใน URL (`useTableSearchParams`) ต้องใช้ next/navigation → อยู่ที่ `@suphark/ui/next`
|
|
9
|
+
*/
|
|
10
|
+
export * from "./components/data-table";
|
|
11
|
+
export { DataTableRowActions } from "./components/data-table/data-table-row-actions";
|
|
12
|
+
export { DeleteManyButton } from "./components/shared/button/delete-many-button";
|
|
13
|
+
export {
|
|
14
|
+
countActiveResourcePropertyFilters,
|
|
15
|
+
filterResourcePropertyDefinitions,
|
|
16
|
+
isResourcePropertyFilterActive,
|
|
17
|
+
ResourcePropertyFilter,
|
|
18
|
+
type ResourcePropertyFilterDefinition,
|
|
19
|
+
type ResourcePropertyFilterState,
|
|
20
|
+
type ResourcePropertyFilterValue,
|
|
21
|
+
} from "./components/shared/resource-property-filter";
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
|
4
|
+
import { useCallback, useMemo, useTransition } from "react";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* เก็บ state ของตาราง (หน้า, ขนาดหน้า, การเรียง, คำค้น, ตัวกรอง) ไว้ใน URL
|
|
8
|
+
*
|
|
9
|
+
* เก็บใน URL ไม่ใช่ useState เพราะ:
|
|
10
|
+
* - server component อ่าน searchParams แล้ว query เฉพาะหน้าที่ขอได้
|
|
11
|
+
* - ก๊อป URL ส่งต่อ หรือ refresh แล้วเห็นตารางหน้าเดิม
|
|
12
|
+
*
|
|
13
|
+
* ใช้ router.replace ไม่ใช่ push โดยตั้งใจ — การกดตัวกรองทีละครั้งไม่ควรถมประวัติ
|
|
14
|
+
* เบราว์เซอร์จนกดย้อนกลับออกจากหน้าไม่ได้ ผลคือปุ่ม back จะพาออกจากหน้านี้เลย
|
|
15
|
+
* ไม่ได้ย้อนทีละสถานะของตาราง
|
|
16
|
+
*
|
|
17
|
+
* ทุกการเปลี่ยนค่าห่อด้วย startTransition เพื่อให้ React แสดงข้อมูลเดิมค้างไว้
|
|
18
|
+
* ระหว่างรอ server ตอบ แทนที่จะกระพริบเป็นหน้าว่าง
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export const TABLE_PARAM = {
|
|
22
|
+
page: "page",
|
|
23
|
+
pageSize: "pageSize",
|
|
24
|
+
sort: "sort",
|
|
25
|
+
order: "order",
|
|
26
|
+
search: "q",
|
|
27
|
+
} as const;
|
|
28
|
+
|
|
29
|
+
/** ชื่อ param ที่สงวนไว้ ที่เหลือถือเป็น faceted filter */
|
|
30
|
+
const RESERVED = new Set<string>(Object.values(TABLE_PARAM));
|
|
31
|
+
|
|
32
|
+
export type TableSearchState = {
|
|
33
|
+
page: number;
|
|
34
|
+
pageSize: number;
|
|
35
|
+
sort: string | null;
|
|
36
|
+
order: "asc" | "desc";
|
|
37
|
+
search: string;
|
|
38
|
+
filters: Record<string, string[]>;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export function useTableSearchParams(defaults?: { pageSize?: number }) {
|
|
42
|
+
const router = useRouter();
|
|
43
|
+
const pathname = usePathname();
|
|
44
|
+
const searchParams = useSearchParams();
|
|
45
|
+
const [isPending, startTransition] = useTransition();
|
|
46
|
+
|
|
47
|
+
const state = useMemo<TableSearchState>(() => {
|
|
48
|
+
const filters: Record<string, string[]> = {};
|
|
49
|
+
for (const key of new Set(searchParams.keys())) {
|
|
50
|
+
if (RESERVED.has(key)) continue;
|
|
51
|
+
const values = searchParams
|
|
52
|
+
.getAll(key)
|
|
53
|
+
.flatMap((v) => v.split(","))
|
|
54
|
+
.filter(Boolean);
|
|
55
|
+
if (values.length > 0) filters[key] = values;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const page = Number.parseInt(searchParams.get(TABLE_PARAM.page) ?? "1", 10);
|
|
59
|
+
const pageSize = Number.parseInt(
|
|
60
|
+
searchParams.get(TABLE_PARAM.pageSize) ?? String(defaults?.pageSize ?? 50),
|
|
61
|
+
10,
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
page: Number.isFinite(page) && page > 0 ? page : 1,
|
|
66
|
+
pageSize: Number.isFinite(pageSize) && pageSize > 0 ? pageSize : (defaults?.pageSize ?? 50),
|
|
67
|
+
sort: searchParams.get(TABLE_PARAM.sort),
|
|
68
|
+
order: searchParams.get(TABLE_PARAM.order) === "asc" ? "asc" : "desc",
|
|
69
|
+
search: searchParams.get(TABLE_PARAM.search) ?? "",
|
|
70
|
+
filters,
|
|
71
|
+
};
|
|
72
|
+
}, [searchParams, defaults?.pageSize]);
|
|
73
|
+
|
|
74
|
+
const push = useCallback(
|
|
75
|
+
(mutate: (params: URLSearchParams) => void) => {
|
|
76
|
+
const params = new URLSearchParams(searchParams.toString());
|
|
77
|
+
mutate(params);
|
|
78
|
+
startTransition(() => {
|
|
79
|
+
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
|
|
80
|
+
});
|
|
81
|
+
},
|
|
82
|
+
[router, pathname, searchParams],
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
/** ตั้งค่าแล้วลบ param ทิ้งเมื่อค่าว่าง เพื่อไม่ให้ URL รก */
|
|
86
|
+
const setParam = useCallback((params: URLSearchParams, key: string, value: string | null) => {
|
|
87
|
+
if (value === null || value === "") params.delete(key);
|
|
88
|
+
else params.set(key, value);
|
|
89
|
+
}, []);
|
|
90
|
+
|
|
91
|
+
const setPage = useCallback(
|
|
92
|
+
(page: number) => push((p) => setParam(p, TABLE_PARAM.page, page > 1 ? String(page) : null)),
|
|
93
|
+
[push, setParam],
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const setPageSize = useCallback(
|
|
97
|
+
(pageSize: number) =>
|
|
98
|
+
push((p) => {
|
|
99
|
+
setParam(p, TABLE_PARAM.pageSize, String(pageSize));
|
|
100
|
+
// เปลี่ยนขนาดหน้าแล้วหน้าเดิมอาจไม่มีอยู่จริง กลับไปหน้าแรกเสมอ
|
|
101
|
+
p.delete(TABLE_PARAM.page);
|
|
102
|
+
}),
|
|
103
|
+
[push, setParam],
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
const setSort = useCallback(
|
|
107
|
+
(field: string | null, order: "asc" | "desc" = "desc") =>
|
|
108
|
+
push((p) => {
|
|
109
|
+
setParam(p, TABLE_PARAM.sort, field);
|
|
110
|
+
setParam(p, TABLE_PARAM.order, field ? order : null);
|
|
111
|
+
p.delete(TABLE_PARAM.page);
|
|
112
|
+
}),
|
|
113
|
+
[push, setParam],
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
const setSearch = useCallback(
|
|
117
|
+
(search: string) =>
|
|
118
|
+
push((p) => {
|
|
119
|
+
setParam(p, TABLE_PARAM.search, search.trim());
|
|
120
|
+
p.delete(TABLE_PARAM.page);
|
|
121
|
+
}),
|
|
122
|
+
[push, setParam],
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const setFilter = useCallback(
|
|
126
|
+
(key: string, values: string[]) =>
|
|
127
|
+
push((p) => {
|
|
128
|
+
setParam(p, key, values.length > 0 ? values.join(",") : null);
|
|
129
|
+
p.delete(TABLE_PARAM.page);
|
|
130
|
+
}),
|
|
131
|
+
[push, setParam],
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
const resetFilters = useCallback(
|
|
135
|
+
() =>
|
|
136
|
+
push((p) => {
|
|
137
|
+
for (const key of [...p.keys()]) {
|
|
138
|
+
if (!RESERVED.has(key)) p.delete(key);
|
|
139
|
+
}
|
|
140
|
+
p.delete(TABLE_PARAM.search);
|
|
141
|
+
p.delete(TABLE_PARAM.page);
|
|
142
|
+
}),
|
|
143
|
+
[push],
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
return { state, isPending, setPage, setPageSize, setSort, setSearch, setFilter, resetFilters };
|
|
147
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
* `chart` (recharts) ไม่อยู่ใน barrel: import ตรง `@suphark/ui/components/ui/chart` เมื่อแอปติดตั้ง recharts เอง
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
// sonner: `toast()` คู่กับ <Toaster /> — แอปไม่ต้องติดตั้ง sonner เอง
|
|
13
|
+
export { toast } from "sonner";
|
|
12
14
|
export * from "./components/shared/button";
|
|
13
15
|
export { ClientRedirect } from "./components/shared/client-redirect";
|
|
14
16
|
export { Combobox } from "./components/shared/combobox";
|
|
@@ -131,7 +133,6 @@ export * from "./components/ui/textarea";
|
|
|
131
133
|
export * from "./components/ui/toggle";
|
|
132
134
|
export * from "./components/ui/toggle-group";
|
|
133
135
|
export * from "./components/ui/tooltip";
|
|
134
|
-
|
|
135
136
|
// ---------- lib / hooks ----------
|
|
136
137
|
export { useCopyToClipboard } from "./hooks/use-copy-to-clipboard";
|
|
137
138
|
export { useIsMobile } from "./hooks/use-mobile";
|
package/src/next.ts
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
* @suphark/ui/next — component ที่ต้องรันใน Next.js (ใช้ next/navigation หรือ next/link)
|
|
3
3
|
* แยกจาก barrel หลักเพื่อให้ `@suphark/ui` ใช้ได้ในสภาพแวดล้อมที่ไม่มี next (vitest, Vite, Storybook)
|
|
4
4
|
*/
|
|
5
|
+
|
|
6
|
+
// ---------- app shell (sidebar + header + เนื้อหา) ----------
|
|
7
|
+
export * from "./components/app-shell";
|
|
5
8
|
export { LinkButton, type LinkButtonProps } from "./components/shared/button/base/link-button";
|
|
6
9
|
export { RefreshButton } from "./components/shared/button/refresh-button";
|
|
7
10
|
export { FormDialog } from "./components/shared/form-dialog";
|
|
@@ -13,3 +16,8 @@ export {
|
|
|
13
16
|
} from "./components/shared/navigation/page-breadcrumb";
|
|
14
17
|
export { type RowAction, RowActions } from "./components/shared/row-actions";
|
|
15
18
|
export { RowMenu, type RowMenuDialog, type RowMenuItem } from "./components/shared/row-menu";
|
|
19
|
+
export {
|
|
20
|
+
TABLE_PARAM,
|
|
21
|
+
type TableSearchState,
|
|
22
|
+
useTableSearchParams,
|
|
23
|
+
} from "./hooks/use-table-search-params";
|