@handled-ai/design-system 0.16.0 → 0.16.1
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/dist/components/badge.d.ts +1 -1
- package/dist/components/button.d.ts +1 -1
- package/dist/components/data-table-condition-filter.d.ts +37 -0
- package/dist/components/data-table-condition-filter.js +407 -0
- package/dist/components/data-table-condition-filter.js.map +1 -0
- package/dist/components/data-table-filter.d.ts +12 -1
- package/dist/components/data-table-filter.js +92 -10
- package/dist/components/data-table-filter.js.map +1 -1
- package/dist/components/data-table-toolbar.d.ts +1 -0
- package/dist/components/data-table.d.ts +1 -0
- package/dist/components/tabs.d.ts +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/prototype/index.d.ts +1 -0
- package/dist/prototype/prototype-accounts-view.d.ts +1 -0
- package/dist/prototype/prototype-admin-view.d.ts +1 -0
- package/dist/prototype/prototype-config.d.ts +1 -0
- package/dist/prototype/prototype-inbox-view.d.ts +4 -1
- package/dist/prototype/prototype-inbox-view.js +6 -1
- package/dist/prototype/prototype-inbox-view.js.map +1 -1
- package/dist/prototype/prototype-insights-view.d.ts +1 -0
- package/dist/prototype/prototype-shell.d.ts +1 -0
- package/package.json +1 -1
- package/src/components/__tests__/contact-list.test.tsx +1 -1
- package/src/components/__tests__/data-table-condition-filter.test.tsx +397 -0
- package/src/components/__tests__/data-table-filter-presets.test.tsx +1 -1
- package/src/components/__tests__/data-table-filter.test.tsx +270 -3
- package/src/components/data-table-condition-filter.tsx +513 -0
- package/src/components/data-table-filter.tsx +102 -4
- package/src/index.ts +1 -0
- package/src/prototype/__tests__/detail-view-attention.test.tsx +101 -0
- package/src/prototype/prototype-inbox-view.tsx +8 -0
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import {
|
|
5
|
+
CalendarDays,
|
|
6
|
+
DollarSign,
|
|
7
|
+
Eye,
|
|
8
|
+
Hash,
|
|
9
|
+
MoreHorizontal,
|
|
10
|
+
Plus,
|
|
11
|
+
Trash2,
|
|
12
|
+
Type,
|
|
13
|
+
} from "lucide-react"
|
|
14
|
+
|
|
15
|
+
import { cn } from "../lib/utils"
|
|
16
|
+
import { Button } from "./button"
|
|
17
|
+
import { Input } from "./input"
|
|
18
|
+
import {
|
|
19
|
+
Select,
|
|
20
|
+
SelectContent,
|
|
21
|
+
SelectItem,
|
|
22
|
+
SelectTrigger,
|
|
23
|
+
SelectValue,
|
|
24
|
+
} from "./select"
|
|
25
|
+
|
|
26
|
+
// ── Types ──────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
export type ConditionOperator =
|
|
29
|
+
| "eq"
|
|
30
|
+
| "neq"
|
|
31
|
+
| "gt"
|
|
32
|
+
| "gte"
|
|
33
|
+
| "lt"
|
|
34
|
+
| "lte"
|
|
35
|
+
| "in"
|
|
36
|
+
| "is_null"
|
|
37
|
+
| "is_not_null"
|
|
38
|
+
|
|
39
|
+
export interface ConditionFieldDef {
|
|
40
|
+
/** Unique field key (e.g., "Account_Balance__c") */
|
|
41
|
+
id: string
|
|
42
|
+
/** Display label (e.g., "Account Balance") */
|
|
43
|
+
label: string
|
|
44
|
+
/** Field data type — determines which operators are available and how the value input renders */
|
|
45
|
+
type: "text" | "number" | "currency" | "date"
|
|
46
|
+
/** Allowed operators for this field. Defaults based on type if not provided. */
|
|
47
|
+
operators?: ConditionOperator[]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ConditionFilterValue {
|
|
51
|
+
/** Stable identity — used as React key to avoid stale-state bugs on removal */
|
|
52
|
+
id: string
|
|
53
|
+
field: string
|
|
54
|
+
operator: ConditionOperator
|
|
55
|
+
value: string | number | null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface DataTableConditionFilterProps {
|
|
59
|
+
/** Available fields the user can filter on */
|
|
60
|
+
fields: ConditionFieldDef[]
|
|
61
|
+
/** Current active conditions */
|
|
62
|
+
conditions: ConditionFilterValue[]
|
|
63
|
+
/** Called when conditions change (add, update, remove) */
|
|
64
|
+
onConditionsChange: (conditions: ConditionFilterValue[]) => void
|
|
65
|
+
className?: string
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ── Constants ──────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
const OPERATOR_LABELS: Record<ConditionOperator, string> = {
|
|
71
|
+
eq: "=",
|
|
72
|
+
neq: "≠",
|
|
73
|
+
gt: ">",
|
|
74
|
+
gte: "≥",
|
|
75
|
+
lt: "<",
|
|
76
|
+
lte: "≤",
|
|
77
|
+
in: "contains",
|
|
78
|
+
is_null: "is empty",
|
|
79
|
+
is_not_null: "is not empty",
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const NUMERIC_OPERATORS: ConditionOperator[] = [
|
|
83
|
+
"eq",
|
|
84
|
+
"neq",
|
|
85
|
+
"gt",
|
|
86
|
+
"gte",
|
|
87
|
+
"lt",
|
|
88
|
+
"lte",
|
|
89
|
+
"is_null",
|
|
90
|
+
"is_not_null",
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
const DEFAULT_OPERATORS: Record<ConditionFieldDef["type"], ConditionOperator[]> = {
|
|
94
|
+
text: ["eq", "neq", "is_null", "is_not_null"],
|
|
95
|
+
number: NUMERIC_OPERATORS,
|
|
96
|
+
currency: NUMERIC_OPERATORS,
|
|
97
|
+
date: ["gt", "gte", "lt", "lte", "eq", "neq", "is_null", "is_not_null"],
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Generate a stable unique ID for a new condition row. */
|
|
101
|
+
function generateConditionId(): string {
|
|
102
|
+
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
|
103
|
+
return crypto.randomUUID()
|
|
104
|
+
}
|
|
105
|
+
return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ── Helpers ────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
function getOperators(field: ConditionFieldDef): ConditionOperator[] {
|
|
111
|
+
return (field.operators ?? DEFAULT_OPERATORS[field.type]).filter((op) => op !== "in")
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function isUnaryOperator(op: ConditionOperator): boolean {
|
|
115
|
+
return op === "is_null" || op === "is_not_null"
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function getDefaultOperator(field: ConditionFieldDef): ConditionOperator {
|
|
119
|
+
return getOperators(field)[0] ?? "eq"
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function createDraftCondition(field: ConditionFieldDef): ConditionFilterValue {
|
|
123
|
+
return {
|
|
124
|
+
id: generateConditionId(),
|
|
125
|
+
field: field.id,
|
|
126
|
+
operator: getDefaultOperator(field),
|
|
127
|
+
value: null,
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function normalizeCondition(
|
|
132
|
+
condition: ConditionFilterValue,
|
|
133
|
+
fields: ConditionFieldDef[],
|
|
134
|
+
): ConditionFilterValue {
|
|
135
|
+
const field = fields.find((f) => f.id === condition.field) ?? fields[0]
|
|
136
|
+
if (!field) return condition
|
|
137
|
+
|
|
138
|
+
const operators = getOperators(field)
|
|
139
|
+
const operator = operators.includes(condition.operator)
|
|
140
|
+
? condition.operator
|
|
141
|
+
: getDefaultOperator(field)
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
...condition,
|
|
145
|
+
field: field.id,
|
|
146
|
+
operator,
|
|
147
|
+
value: isUnaryOperator(operator) ? null : condition.value,
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function parseConditionValue(
|
|
152
|
+
raw: string,
|
|
153
|
+
fieldType: ConditionFieldDef["type"],
|
|
154
|
+
): string | number | null {
|
|
155
|
+
if (raw === "") return null
|
|
156
|
+
if (fieldType === "number" || fieldType === "currency") {
|
|
157
|
+
const parsed = Number(raw)
|
|
158
|
+
return Number.isNaN(parsed) ? null : parsed
|
|
159
|
+
}
|
|
160
|
+
return raw
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function isCompleteCondition(
|
|
164
|
+
condition: ConditionFilterValue,
|
|
165
|
+
fields: ConditionFieldDef[],
|
|
166
|
+
): boolean {
|
|
167
|
+
const field = fields.find((f) => f.id === condition.field)
|
|
168
|
+
if (!field) return false
|
|
169
|
+
if (!getOperators(field).includes(condition.operator)) return false
|
|
170
|
+
if (isUnaryOperator(condition.operator)) return true
|
|
171
|
+
return condition.value !== null && condition.value !== ""
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function getFieldIcon(type: ConditionFieldDef["type"]) {
|
|
175
|
+
if (type === "currency") return DollarSign
|
|
176
|
+
if (type === "number") return Hash
|
|
177
|
+
if (type === "date") return CalendarDays
|
|
178
|
+
return Type
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ── Condition Row ──────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
interface ConditionRowProps {
|
|
184
|
+
condition: ConditionFilterValue
|
|
185
|
+
fields: ConditionFieldDef[]
|
|
186
|
+
index: number
|
|
187
|
+
onChange: (updated: ConditionFilterValue) => void
|
|
188
|
+
onRemove: () => void
|
|
189
|
+
onCommit: () => void
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function ConditionRow({
|
|
193
|
+
condition,
|
|
194
|
+
fields,
|
|
195
|
+
index,
|
|
196
|
+
onChange,
|
|
197
|
+
onRemove,
|
|
198
|
+
onCommit,
|
|
199
|
+
}: ConditionRowProps) {
|
|
200
|
+
const fieldDef = fields.find((f) => f.id === condition.field) ?? fields[0]
|
|
201
|
+
const operators = getOperators(fieldDef)
|
|
202
|
+
const isUnary = isUnaryOperator(condition.operator)
|
|
203
|
+
const FieldIcon = getFieldIcon(fieldDef.type)
|
|
204
|
+
|
|
205
|
+
const handleFieldChange = (newFieldId: string) => {
|
|
206
|
+
const newFieldDef = fields.find((f) => f.id === newFieldId) ?? fields[0]
|
|
207
|
+
if (!newFieldDef) return
|
|
208
|
+
onChange({
|
|
209
|
+
...condition,
|
|
210
|
+
field: newFieldDef.id,
|
|
211
|
+
operator: getDefaultOperator(newFieldDef),
|
|
212
|
+
value: null,
|
|
213
|
+
})
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const handleOperatorChange = (newOp: ConditionOperator) => {
|
|
217
|
+
onChange({
|
|
218
|
+
...condition,
|
|
219
|
+
operator: newOp,
|
|
220
|
+
value: isUnaryOperator(newOp) ? null : condition.value,
|
|
221
|
+
})
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const handleValueChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
225
|
+
onChange({
|
|
226
|
+
...condition,
|
|
227
|
+
value: parseConditionValue(event.target.value, fieldDef.type),
|
|
228
|
+
})
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return (
|
|
232
|
+
<div
|
|
233
|
+
className="grid grid-cols-[52px_minmax(150px,1fr)_120px_minmax(140px,1fr)_auto] items-center gap-2 rounded-lg border border-border/70 bg-background p-2 shadow-sm"
|
|
234
|
+
data-slot="condition-row"
|
|
235
|
+
>
|
|
236
|
+
<div className="text-xs font-medium text-muted-foreground">
|
|
237
|
+
{index === 0 ? "Where" : "And"}
|
|
238
|
+
</div>
|
|
239
|
+
|
|
240
|
+
<Select value={condition.field} onValueChange={handleFieldChange}>
|
|
241
|
+
<SelectTrigger className="h-8 w-full justify-start gap-2" size="sm">
|
|
242
|
+
<FieldIcon className="h-3.5 w-3.5 text-muted-foreground" />
|
|
243
|
+
<SelectValue placeholder={fieldDef.label} />
|
|
244
|
+
</SelectTrigger>
|
|
245
|
+
<SelectContent>
|
|
246
|
+
{fields.map((field) => {
|
|
247
|
+
const Icon = getFieldIcon(field.type)
|
|
248
|
+
return (
|
|
249
|
+
<SelectItem key={field.id} value={field.id}>
|
|
250
|
+
<span className="inline-flex items-center gap-2">
|
|
251
|
+
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
|
|
252
|
+
{field.label}
|
|
253
|
+
</span>
|
|
254
|
+
</SelectItem>
|
|
255
|
+
)
|
|
256
|
+
})}
|
|
257
|
+
</SelectContent>
|
|
258
|
+
</Select>
|
|
259
|
+
|
|
260
|
+
<Select
|
|
261
|
+
value={condition.operator}
|
|
262
|
+
onValueChange={(value) => handleOperatorChange(value as ConditionOperator)}
|
|
263
|
+
>
|
|
264
|
+
<SelectTrigger className="h-8 w-full" size="sm">
|
|
265
|
+
<SelectValue placeholder="Operator" />
|
|
266
|
+
</SelectTrigger>
|
|
267
|
+
<SelectContent>
|
|
268
|
+
{operators.map((op) => (
|
|
269
|
+
<SelectItem key={op} value={op}>
|
|
270
|
+
{OPERATOR_LABELS[op]}
|
|
271
|
+
</SelectItem>
|
|
272
|
+
))}
|
|
273
|
+
</SelectContent>
|
|
274
|
+
</Select>
|
|
275
|
+
|
|
276
|
+
{isUnary ? (
|
|
277
|
+
<div className="h-8 rounded-md border border-dashed border-border/70 bg-muted/30 px-3 py-1.5 text-xs text-muted-foreground">
|
|
278
|
+
No value needed
|
|
279
|
+
</div>
|
|
280
|
+
) : (
|
|
281
|
+
<div className="relative flex items-center">
|
|
282
|
+
{fieldDef.type === "currency" ? (
|
|
283
|
+
<span className="pointer-events-none absolute left-2 text-sm text-muted-foreground">
|
|
284
|
+
$
|
|
285
|
+
</span>
|
|
286
|
+
) : null}
|
|
287
|
+
<Input
|
|
288
|
+
type={
|
|
289
|
+
fieldDef.type === "number" || fieldDef.type === "currency"
|
|
290
|
+
? "number"
|
|
291
|
+
: fieldDef.type === "date"
|
|
292
|
+
? "date"
|
|
293
|
+
: "text"
|
|
294
|
+
}
|
|
295
|
+
value={condition.value != null ? String(condition.value) : ""}
|
|
296
|
+
onChange={handleValueChange}
|
|
297
|
+
onClick={(event) => event.stopPropagation()}
|
|
298
|
+
onKeyDown={(event) => {
|
|
299
|
+
event.stopPropagation()
|
|
300
|
+
if (event.key === "Enter") {
|
|
301
|
+
onCommit()
|
|
302
|
+
}
|
|
303
|
+
}}
|
|
304
|
+
placeholder={
|
|
305
|
+
fieldDef.type === "currency"
|
|
306
|
+
? "Amount"
|
|
307
|
+
: fieldDef.type === "number"
|
|
308
|
+
? "Enter number..."
|
|
309
|
+
: fieldDef.type === "date"
|
|
310
|
+
? ""
|
|
311
|
+
: "Enter value..."
|
|
312
|
+
}
|
|
313
|
+
className={cn("h-8", fieldDef.type === "currency" && "pl-6")}
|
|
314
|
+
/>
|
|
315
|
+
</div>
|
|
316
|
+
)}
|
|
317
|
+
|
|
318
|
+
<div className="flex items-center gap-1">
|
|
319
|
+
<Button
|
|
320
|
+
type="button"
|
|
321
|
+
variant="ghost"
|
|
322
|
+
size="icon"
|
|
323
|
+
className="h-8 w-8 text-muted-foreground"
|
|
324
|
+
aria-label="Toggle condition visibility"
|
|
325
|
+
onClick={(event) => event.preventDefault()}
|
|
326
|
+
>
|
|
327
|
+
<Eye className="size-4" />
|
|
328
|
+
</Button>
|
|
329
|
+
<Button
|
|
330
|
+
type="button"
|
|
331
|
+
variant="ghost"
|
|
332
|
+
size="icon"
|
|
333
|
+
className="h-8 w-8 text-muted-foreground"
|
|
334
|
+
aria-label="More condition actions"
|
|
335
|
+
onClick={(event) => event.preventDefault()}
|
|
336
|
+
>
|
|
337
|
+
<MoreHorizontal className="size-4" />
|
|
338
|
+
</Button>
|
|
339
|
+
<Button
|
|
340
|
+
type="button"
|
|
341
|
+
variant="ghost"
|
|
342
|
+
size="icon"
|
|
343
|
+
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
|
344
|
+
onClick={onRemove}
|
|
345
|
+
aria-label="Remove condition"
|
|
346
|
+
>
|
|
347
|
+
<Trash2 className="size-4" />
|
|
348
|
+
</Button>
|
|
349
|
+
</div>
|
|
350
|
+
</div>
|
|
351
|
+
)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ── Main Component ─────────────────────────────────────────────
|
|
355
|
+
|
|
356
|
+
function DataTableConditionFilter({
|
|
357
|
+
fields,
|
|
358
|
+
conditions,
|
|
359
|
+
onConditionsChange,
|
|
360
|
+
className,
|
|
361
|
+
}: DataTableConditionFilterProps) {
|
|
362
|
+
const [drafts, setDrafts] = React.useState<ConditionFilterValue[]>(() =>
|
|
363
|
+
conditions.map((condition) => normalizeCondition(condition, fields)),
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
React.useEffect(() => {
|
|
367
|
+
setDrafts(conditions.map((condition) => normalizeCondition(condition, fields)))
|
|
368
|
+
}, [conditions, fields])
|
|
369
|
+
|
|
370
|
+
const commitDrafts = React.useCallback(
|
|
371
|
+
(nextDrafts: ConditionFilterValue[] = drafts) => {
|
|
372
|
+
onConditionsChange(
|
|
373
|
+
nextDrafts
|
|
374
|
+
.map((condition) => normalizeCondition(condition, fields))
|
|
375
|
+
.filter((condition) => isCompleteCondition(condition, fields)),
|
|
376
|
+
)
|
|
377
|
+
},
|
|
378
|
+
[drafts, fields, onConditionsChange],
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
const handleAdd = () => {
|
|
382
|
+
const firstField = fields[0]
|
|
383
|
+
if (!firstField) return
|
|
384
|
+
const committedDrafts = drafts.filter((condition) =>
|
|
385
|
+
isCompleteCondition(condition, fields),
|
|
386
|
+
)
|
|
387
|
+
const nextDrafts = [...committedDrafts, createDraftCondition(firstField)]
|
|
388
|
+
setDrafts(nextDrafts)
|
|
389
|
+
commitDrafts(committedDrafts)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const handleUpdate = (index: number, updated: ConditionFilterValue) => {
|
|
393
|
+
setDrafts((current) => {
|
|
394
|
+
const next = [...current]
|
|
395
|
+
next[index] = normalizeCondition(updated, fields)
|
|
396
|
+
return next
|
|
397
|
+
})
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const handleRemove = (index: number) => {
|
|
401
|
+
setDrafts((current) => {
|
|
402
|
+
const next = current.filter((_, currentIndex) => currentIndex !== index)
|
|
403
|
+
commitDrafts(next)
|
|
404
|
+
return next
|
|
405
|
+
})
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const handleClear = () => {
|
|
409
|
+
setDrafts([])
|
|
410
|
+
onConditionsChange([])
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const hasAppliedConditions = conditions.length > 0
|
|
414
|
+
const hasDrafts = drafts.length > 0
|
|
415
|
+
|
|
416
|
+
return (
|
|
417
|
+
<div
|
|
418
|
+
className={cn(
|
|
419
|
+
"w-[min(760px,calc(100vw-2rem))] rounded-xl border border-border bg-background p-3 text-foreground shadow-xl",
|
|
420
|
+
className,
|
|
421
|
+
)}
|
|
422
|
+
data-slot="condition-filter"
|
|
423
|
+
onClick={(event) => event.stopPropagation()}
|
|
424
|
+
onKeyDown={(event) => {
|
|
425
|
+
if (event.key !== "Escape") {
|
|
426
|
+
event.stopPropagation()
|
|
427
|
+
}
|
|
428
|
+
}}
|
|
429
|
+
>
|
|
430
|
+
<div className="mb-3 flex items-center justify-between gap-3 border-b border-border/70 pb-3">
|
|
431
|
+
<div>
|
|
432
|
+
<div className="text-sm font-semibold">Filter builder</div>
|
|
433
|
+
<div className="text-xs text-muted-foreground">
|
|
434
|
+
Build field, operator, and value conditions.
|
|
435
|
+
</div>
|
|
436
|
+
</div>
|
|
437
|
+
<Button
|
|
438
|
+
type="button"
|
|
439
|
+
variant="ghost"
|
|
440
|
+
size="sm"
|
|
441
|
+
className="h-8 text-xs text-destructive hover:text-destructive"
|
|
442
|
+
onClick={handleClear}
|
|
443
|
+
disabled={!hasAppliedConditions && !hasDrafts}
|
|
444
|
+
>
|
|
445
|
+
Clear filters
|
|
446
|
+
</Button>
|
|
447
|
+
</div>
|
|
448
|
+
|
|
449
|
+
<div className="flex flex-col gap-2">
|
|
450
|
+
{drafts.map((condition, index) => (
|
|
451
|
+
<ConditionRow
|
|
452
|
+
key={condition.id}
|
|
453
|
+
condition={condition}
|
|
454
|
+
fields={fields}
|
|
455
|
+
index={index}
|
|
456
|
+
onChange={(updated) => handleUpdate(index, updated)}
|
|
457
|
+
onRemove={() => handleRemove(index)}
|
|
458
|
+
onCommit={() => commitDrafts()}
|
|
459
|
+
/>
|
|
460
|
+
))}
|
|
461
|
+
</div>
|
|
462
|
+
|
|
463
|
+
{!hasDrafts ? (
|
|
464
|
+
<div className="rounded-lg border border-dashed border-border/80 bg-muted/20 px-3 py-5 text-center text-xs text-muted-foreground">
|
|
465
|
+
No builder filters yet. Add a filter to start a condition row.
|
|
466
|
+
</div>
|
|
467
|
+
) : null}
|
|
468
|
+
|
|
469
|
+
<div className="mt-3 flex flex-wrap items-center justify-between gap-2 border-t border-border/70 pt-3">
|
|
470
|
+
<div className="flex items-center gap-2">
|
|
471
|
+
<Button
|
|
472
|
+
type="button"
|
|
473
|
+
variant="ghost"
|
|
474
|
+
size="sm"
|
|
475
|
+
className="h-8 text-xs text-muted-foreground"
|
|
476
|
+
onClick={handleAdd}
|
|
477
|
+
>
|
|
478
|
+
<Plus className="mr-1 size-3.5" />
|
|
479
|
+
Add filter
|
|
480
|
+
</Button>
|
|
481
|
+
<Button
|
|
482
|
+
type="button"
|
|
483
|
+
variant="ghost"
|
|
484
|
+
size="sm"
|
|
485
|
+
className="h-8 text-xs text-muted-foreground opacity-60"
|
|
486
|
+
disabled
|
|
487
|
+
aria-disabled="true"
|
|
488
|
+
>
|
|
489
|
+
<Plus className="mr-1 size-3.5" />
|
|
490
|
+
Add filter group
|
|
491
|
+
</Button>
|
|
492
|
+
</div>
|
|
493
|
+
<Button
|
|
494
|
+
type="button"
|
|
495
|
+
size="sm"
|
|
496
|
+
className="h-8 text-xs"
|
|
497
|
+
onClick={() => commitDrafts()}
|
|
498
|
+
>
|
|
499
|
+
Apply
|
|
500
|
+
</Button>
|
|
501
|
+
</div>
|
|
502
|
+
</div>
|
|
503
|
+
)
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
export {
|
|
507
|
+
DataTableConditionFilter,
|
|
508
|
+
OPERATOR_LABELS,
|
|
509
|
+
DEFAULT_OPERATORS,
|
|
510
|
+
generateConditionId,
|
|
511
|
+
getOperators,
|
|
512
|
+
}
|
|
513
|
+
export type { DataTableConditionFilterProps }
|
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
3
|
import * as React from "react"
|
|
4
|
-
import { ListFilter, Search } from "lucide-react"
|
|
4
|
+
import { Check, ListFilter, Plus, Search } from "lucide-react"
|
|
5
|
+
|
|
6
|
+
import { Popover as PopoverPrimitive } from "radix-ui"
|
|
5
7
|
|
|
6
8
|
import { cn } from "../lib/utils"
|
|
7
9
|
import { Button } from "./button"
|
|
10
|
+
import {
|
|
11
|
+
DataTableConditionFilter,
|
|
12
|
+
type ConditionFieldDef,
|
|
13
|
+
type ConditionFilterValue,
|
|
14
|
+
} from "./data-table-condition-filter"
|
|
8
15
|
import {
|
|
9
16
|
DropdownMenu,
|
|
10
17
|
DropdownMenuContent,
|
|
@@ -25,6 +32,8 @@ export interface DataTableFilterCategory {
|
|
|
25
32
|
label: string
|
|
26
33
|
icon: React.ComponentType<{ className?: string }>
|
|
27
34
|
options: (string | FilterOption)[]
|
|
35
|
+
/** Filter behavior. Defaults to "multi" (checkbox multi-select). */
|
|
36
|
+
type?: "multi" | "single" | "boolean"
|
|
28
37
|
}
|
|
29
38
|
|
|
30
39
|
function getOptionValue(option: string | FilterOption): string {
|
|
@@ -47,6 +56,14 @@ export interface DataTableFilterProps {
|
|
|
47
56
|
onTogglePreset?: (categoryId: string, option: string) => void
|
|
48
57
|
/** Label shown on preset chips to distinguish from user-applied filters. Default: "Default". */
|
|
49
58
|
presetLabel?: string
|
|
59
|
+
/** Fields exposed in the unified condition-builder panel. */
|
|
60
|
+
conditionFields?: ConditionFieldDef[]
|
|
61
|
+
/** Active builder-managed field/operator/value conditions. */
|
|
62
|
+
conditionFilters?: ConditionFilterValue[]
|
|
63
|
+
/** Callback when builder-managed conditions are applied, removed, or cleared. */
|
|
64
|
+
onConditionFiltersChange?: (conditions: ConditionFilterValue[]) => void
|
|
65
|
+
/** Dropdown entry label for the condition-builder panel. Default: "Add filter". */
|
|
66
|
+
conditionBuilderLabel?: string
|
|
50
67
|
}
|
|
51
68
|
|
|
52
69
|
export function DataTableFilter({
|
|
@@ -58,9 +75,15 @@ export function DataTableFilter({
|
|
|
58
75
|
presetFilters,
|
|
59
76
|
onTogglePreset,
|
|
60
77
|
presetLabel = "Default",
|
|
78
|
+
conditionFields = [],
|
|
79
|
+
conditionFilters = [],
|
|
80
|
+
onConditionFiltersChange,
|
|
81
|
+
conditionBuilderLabel = "Add filter",
|
|
61
82
|
}: DataTableFilterProps) {
|
|
62
83
|
const [query, setQuery] = React.useState("")
|
|
63
84
|
const [subQueries, setSubQueries] = React.useState<Record<string, string>>({})
|
|
85
|
+
const [conditionBuilderOpen, setConditionBuilderOpen] = React.useState(false)
|
|
86
|
+
const hasConditionBuilder = conditionFields.length > 0
|
|
64
87
|
|
|
65
88
|
const visibleCategories = React.useMemo(() => {
|
|
66
89
|
const normalized = query.trim().toLowerCase()
|
|
@@ -95,7 +118,7 @@ export function DataTableFilter({
|
|
|
95
118
|
)
|
|
96
119
|
|
|
97
120
|
// Count active preset filters (those that are in presetFilters AND currently active in selectedFilters)
|
|
98
|
-
|
|
121
|
+
const presetCount = 0
|
|
99
122
|
if (presetFilters) {
|
|
100
123
|
for (const [categoryId, presetValues] of Object.entries(presetFilters)) {
|
|
101
124
|
for (const value of presetValues) {
|
|
@@ -109,8 +132,8 @@ export function DataTableFilter({
|
|
|
109
132
|
}
|
|
110
133
|
}
|
|
111
134
|
|
|
112
|
-
return userCount + presetCount
|
|
113
|
-
}, [selectedFilters, presetFilters])
|
|
135
|
+
return userCount + presetCount + conditionFilters.length
|
|
136
|
+
}, [selectedFilters, presetFilters, conditionFilters.length])
|
|
114
137
|
|
|
115
138
|
/** Collect all preset chips to render */
|
|
116
139
|
const presetChips = React.useMemo(() => {
|
|
@@ -170,6 +193,31 @@ export function DataTableFilter({
|
|
|
170
193
|
|
|
171
194
|
<div className="max-h-[320px] overflow-y-auto p-1">
|
|
172
195
|
{visibleCategories.map((category) => {
|
|
196
|
+
const filterType = category.type ?? "multi"
|
|
197
|
+
|
|
198
|
+
/* ── Boolean toggle ─────────────────────────────────── */
|
|
199
|
+
if (filterType === "boolean") {
|
|
200
|
+
const active = selectedFilters[category.id]?.includes("true") ?? false
|
|
201
|
+
return (
|
|
202
|
+
<DropdownMenuItem
|
|
203
|
+
key={category.id}
|
|
204
|
+
className={cn(
|
|
205
|
+
"cursor-pointer py-1.5 text-xs",
|
|
206
|
+
active && "text-brand-purple"
|
|
207
|
+
)}
|
|
208
|
+
onSelect={(event) => {
|
|
209
|
+
event.preventDefault()
|
|
210
|
+
onToggleFilter(category.id, "true")
|
|
211
|
+
}}
|
|
212
|
+
>
|
|
213
|
+
<category.icon className="mr-2 h-3.5 w-3.5" />
|
|
214
|
+
{category.label}
|
|
215
|
+
{active ? <Check className="ml-auto h-4 w-4" /> : null}
|
|
216
|
+
</DropdownMenuItem>
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/* ── Sub-menu (single / multi) ──────────────────────── */
|
|
173
221
|
const subQuery = (subQueries[category.id] ?? "").trim().toLowerCase()
|
|
174
222
|
const filteredOptions = subQuery
|
|
175
223
|
? category.options.filter((opt) =>
|
|
@@ -241,6 +289,8 @@ export function DataTableFilter({
|
|
|
241
289
|
<span className="text-brand-purple text-[10px] font-semibold">
|
|
242
290
|
{presetLabel}
|
|
243
291
|
</span>
|
|
292
|
+
) : filterType === "single" ? (
|
|
293
|
+
<span className="h-1.5 w-1.5 rounded-full bg-current" />
|
|
244
294
|
) : (
|
|
245
295
|
<span className="text-[10px] font-semibold text-brand-purple">
|
|
246
296
|
Applied
|
|
@@ -266,6 +316,54 @@ export function DataTableFilter({
|
|
|
266
316
|
</div>
|
|
267
317
|
) : null}
|
|
268
318
|
</div>
|
|
319
|
+
|
|
320
|
+
{hasConditionBuilder ? (
|
|
321
|
+
<div className="border-t border-border p-1">
|
|
322
|
+
<PopoverPrimitive.Root
|
|
323
|
+
open={conditionBuilderOpen}
|
|
324
|
+
onOpenChange={setConditionBuilderOpen}
|
|
325
|
+
>
|
|
326
|
+
<PopoverPrimitive.Trigger asChild>
|
|
327
|
+
<DropdownMenuItem
|
|
328
|
+
className="cursor-pointer py-1.5 text-xs"
|
|
329
|
+
onSelect={(event) => {
|
|
330
|
+
event.preventDefault()
|
|
331
|
+
setConditionBuilderOpen(true)
|
|
332
|
+
}}
|
|
333
|
+
>
|
|
334
|
+
<Plus className="mr-2 h-3.5 w-3.5 text-muted-foreground" />
|
|
335
|
+
{conditionBuilderLabel}
|
|
336
|
+
{conditionFilters.length > 0 ? (
|
|
337
|
+
<span className="ml-auto rounded bg-muted px-1.5 py-0 text-[10px] font-semibold">
|
|
338
|
+
{conditionFilters.length}
|
|
339
|
+
</span>
|
|
340
|
+
) : null}
|
|
341
|
+
</DropdownMenuItem>
|
|
342
|
+
</PopoverPrimitive.Trigger>
|
|
343
|
+
<PopoverPrimitive.Portal>
|
|
344
|
+
<PopoverPrimitive.Content
|
|
345
|
+
align="start"
|
|
346
|
+
side="right"
|
|
347
|
+
sideOffset={8}
|
|
348
|
+
className="z-60 outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95"
|
|
349
|
+
onEscapeKeyDown={() => setConditionBuilderOpen(false)}
|
|
350
|
+
onInteractOutside={(event) => {
|
|
351
|
+
const target = event.target as HTMLElement | null
|
|
352
|
+
if (target?.closest('[data-slot="dropdown-menu-content"]')) {
|
|
353
|
+
event.preventDefault()
|
|
354
|
+
}
|
|
355
|
+
}}
|
|
356
|
+
>
|
|
357
|
+
<DataTableConditionFilter
|
|
358
|
+
fields={conditionFields}
|
|
359
|
+
conditions={conditionFilters}
|
|
360
|
+
onConditionsChange={onConditionFiltersChange ?? (() => {})}
|
|
361
|
+
/>
|
|
362
|
+
</PopoverPrimitive.Content>
|
|
363
|
+
</PopoverPrimitive.Portal>
|
|
364
|
+
</PopoverPrimitive.Root>
|
|
365
|
+
</div>
|
|
366
|
+
) : null}
|
|
269
367
|
</DropdownMenuContent>
|
|
270
368
|
</DropdownMenu>
|
|
271
369
|
)
|
package/src/index.ts
CHANGED
|
@@ -25,6 +25,7 @@ export * from "./components/contact-chip"
|
|
|
25
25
|
export * from "./components/contact-list"
|
|
26
26
|
export * from "./components/dashboard-cards"
|
|
27
27
|
export * from "./components/data-table"
|
|
28
|
+
export * from "./components/data-table-condition-filter"
|
|
28
29
|
export * from "./components/data-table-display"
|
|
29
30
|
export * from "./components/data-table-filter"
|
|
30
31
|
export * from "./components/data-table-quick-views"
|