@handled-ai/design-system 0.18.10 → 0.18.11
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 +14 -3
- package/dist/components/data-table-condition-filter.js +198 -33
- package/dist/components/data-table-condition-filter.js.map +1 -1
- package/dist/components/data-table-filter.js +1 -1
- package/dist/components/data-table-filter.js.map +1 -1
- package/dist/components/pill.d.ts +1 -1
- package/dist/components/tabs.d.ts +1 -1
- package/dist/index.d.ts +1 -1
- package/package.json +1 -1
- package/src/components/__tests__/data-table-condition-filter.test.tsx +143 -7
- package/src/components/__tests__/data-table-filter.test.tsx +0 -45
- package/src/components/data-table-condition-filter.tsx +274 -49
- package/src/components/data-table-filter.tsx +1 -1
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import * as React from "react"
|
|
4
4
|
import {
|
|
5
5
|
CalendarDays,
|
|
6
|
+
Check,
|
|
6
7
|
DollarSign,
|
|
7
8
|
Eye,
|
|
8
9
|
Hash,
|
|
@@ -10,6 +11,7 @@ import {
|
|
|
10
11
|
Plus,
|
|
11
12
|
Trash2,
|
|
12
13
|
Type,
|
|
14
|
+
type LucideIcon,
|
|
13
15
|
} from "lucide-react"
|
|
14
16
|
|
|
15
17
|
import { cn } from "../lib/utils"
|
|
@@ -36,15 +38,26 @@ export type ConditionOperator =
|
|
|
36
38
|
| "is_null"
|
|
37
39
|
| "is_not_null"
|
|
38
40
|
|
|
41
|
+
export interface ConditionOptionObject {
|
|
42
|
+
label: string
|
|
43
|
+
value: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type ConditionFieldOption = string | ConditionOptionObject
|
|
47
|
+
|
|
39
48
|
export interface ConditionFieldDef {
|
|
40
49
|
/** Unique field key (e.g., "Account_Balance__c") */
|
|
41
50
|
id: string
|
|
42
51
|
/** Display label (e.g., "Account Balance") */
|
|
43
52
|
label: string
|
|
44
53
|
/** Field data type — determines which operators are available and how the value input renders */
|
|
45
|
-
type: "text" | "number" | "currency" | "date"
|
|
54
|
+
type: "text" | "number" | "currency" | "date" | "select" | "multi_select"
|
|
46
55
|
/** Allowed operators for this field. Defaults based on type if not provided. */
|
|
47
56
|
operators?: ConditionOperator[]
|
|
57
|
+
/** Options used by select and multi-select fields. Strings use the same label and value. */
|
|
58
|
+
options?: ConditionFieldOption[]
|
|
59
|
+
/** Show a search box for option-backed value inputs. */
|
|
60
|
+
searchable?: boolean | { threshold?: number }
|
|
48
61
|
}
|
|
49
62
|
|
|
50
63
|
export interface ConditionFilterValue {
|
|
@@ -52,7 +65,7 @@ export interface ConditionFilterValue {
|
|
|
52
65
|
id: string
|
|
53
66
|
field: string
|
|
54
67
|
operator: ConditionOperator
|
|
55
|
-
value: string | number | null
|
|
68
|
+
value: string | number | string[] | null
|
|
56
69
|
}
|
|
57
70
|
|
|
58
71
|
interface DataTableConditionFilterProps {
|
|
@@ -74,7 +87,7 @@ const OPERATOR_LABELS: Record<ConditionOperator, string> = {
|
|
|
74
87
|
gte: "≥",
|
|
75
88
|
lt: "<",
|
|
76
89
|
lte: "≤",
|
|
77
|
-
in: "
|
|
90
|
+
in: "is any of",
|
|
78
91
|
is_null: "is empty",
|
|
79
92
|
is_not_null: "is not empty",
|
|
80
93
|
}
|
|
@@ -95,6 +108,8 @@ const DEFAULT_OPERATORS: Record<ConditionFieldDef["type"], ConditionOperator[]>
|
|
|
95
108
|
number: NUMERIC_OPERATORS,
|
|
96
109
|
currency: NUMERIC_OPERATORS,
|
|
97
110
|
date: ["gt", "gte", "lt", "lte", "eq", "neq", "is_null", "is_not_null"],
|
|
111
|
+
select: ["eq", "neq", "is_null", "is_not_null"],
|
|
112
|
+
multi_select: ["in", "is_null", "is_not_null"],
|
|
98
113
|
}
|
|
99
114
|
|
|
100
115
|
/** Generate a stable unique ID for a new condition row. */
|
|
@@ -108,7 +123,7 @@ function generateConditionId(): string {
|
|
|
108
123
|
// ── Helpers ────────────────────────────────────────────────────
|
|
109
124
|
|
|
110
125
|
function getOperators(field: ConditionFieldDef): ConditionOperator[] {
|
|
111
|
-
return
|
|
126
|
+
return field.operators ?? DEFAULT_OPERATORS[field.type]
|
|
112
127
|
}
|
|
113
128
|
|
|
114
129
|
function isUnaryOperator(op: ConditionOperator): boolean {
|
|
@@ -128,6 +143,18 @@ function createDraftCondition(field: ConditionFieldDef): ConditionFilterValue {
|
|
|
128
143
|
}
|
|
129
144
|
}
|
|
130
145
|
|
|
146
|
+
function normalizeConditionValue(
|
|
147
|
+
value: ConditionFilterValue["value"],
|
|
148
|
+
field: ConditionFieldDef,
|
|
149
|
+
operator: ConditionOperator,
|
|
150
|
+
): ConditionFilterValue["value"] {
|
|
151
|
+
if (isUnaryOperator(operator)) return null
|
|
152
|
+
if (field.type === "multi_select") {
|
|
153
|
+
return Array.isArray(value) ? value : null
|
|
154
|
+
}
|
|
155
|
+
return Array.isArray(value) ? null : value
|
|
156
|
+
}
|
|
157
|
+
|
|
131
158
|
function normalizeCondition(
|
|
132
159
|
condition: ConditionFilterValue,
|
|
133
160
|
fields: ConditionFieldDef[],
|
|
@@ -144,7 +171,7 @@ function normalizeCondition(
|
|
|
144
171
|
...condition,
|
|
145
172
|
field: field.id,
|
|
146
173
|
operator,
|
|
147
|
-
value:
|
|
174
|
+
value: normalizeConditionValue(condition.value, field, operator),
|
|
148
175
|
}
|
|
149
176
|
}
|
|
150
177
|
|
|
@@ -168,18 +195,40 @@ function isCompleteCondition(
|
|
|
168
195
|
if (!field) return false
|
|
169
196
|
if (!getOperators(field).includes(condition.operator)) return false
|
|
170
197
|
if (isUnaryOperator(condition.operator)) return true
|
|
171
|
-
|
|
198
|
+
if (field.type === "multi_select") {
|
|
199
|
+
return Array.isArray(condition.value) && condition.value.length > 0
|
|
200
|
+
}
|
|
201
|
+
return condition.value !== null && condition.value !== "" && !Array.isArray(condition.value)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function getConditionValueSignature(value: ConditionFilterValue["value"]): string {
|
|
205
|
+
return Array.isArray(value) ? JSON.stringify(value) : String(value)
|
|
172
206
|
}
|
|
173
207
|
|
|
174
208
|
function getConditionsSignature(conditions: ConditionFilterValue[]): string {
|
|
175
209
|
return conditions
|
|
176
|
-
.map((condition) => `${condition.id}:${condition.field}:${condition.operator}:${
|
|
210
|
+
.map((condition) => `${condition.id}:${condition.field}:${condition.operator}:${getConditionValueSignature(condition.value)}`)
|
|
177
211
|
.join(";")
|
|
178
212
|
}
|
|
179
213
|
|
|
214
|
+
function normalizeFieldOptions(field: ConditionFieldDef): ConditionOptionObject[] {
|
|
215
|
+
return (field.options ?? []).map((option) =>
|
|
216
|
+
typeof option === "string" ? { label: option, value: option } : option,
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
|
|
180
220
|
function getFieldsSignature(fields: ConditionFieldDef[]): string {
|
|
181
221
|
return fields
|
|
182
|
-
.map((field) =>
|
|
222
|
+
.map((field) => {
|
|
223
|
+
const optionsSignature = normalizeFieldOptions(field)
|
|
224
|
+
.map((option) => `${option.label}:${option.value}`)
|
|
225
|
+
.join("|")
|
|
226
|
+
const searchSignature =
|
|
227
|
+
typeof field.searchable === "object"
|
|
228
|
+
? `threshold:${field.searchable.threshold ?? ""}`
|
|
229
|
+
: String(field.searchable ?? "")
|
|
230
|
+
return `${field.id}:${field.type}:${field.label}:${(field.operators ?? []).join("|")}:${optionsSignature}:${searchSignature}`
|
|
231
|
+
})
|
|
183
232
|
.join(";")
|
|
184
233
|
}
|
|
185
234
|
|
|
@@ -192,15 +241,198 @@ function getCommittedConditions(
|
|
|
192
241
|
.filter((condition) => isCompleteCondition(condition, fields))
|
|
193
242
|
}
|
|
194
243
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
244
|
+
const FIELD_ICON_BY_TYPE: Record<ConditionFieldDef["type"], LucideIcon> = {
|
|
245
|
+
text: Type,
|
|
246
|
+
number: Hash,
|
|
247
|
+
currency: DollarSign,
|
|
248
|
+
date: CalendarDays,
|
|
249
|
+
select: MoreHorizontal,
|
|
250
|
+
multi_select: MoreHorizontal,
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function getFieldIcon(type: ConditionFieldDef["type"]): LucideIcon {
|
|
254
|
+
return FIELD_ICON_BY_TYPE[type]
|
|
200
255
|
}
|
|
201
256
|
|
|
202
257
|
// ── Condition Row ──────────────────────────────────────────────
|
|
203
258
|
|
|
259
|
+
function getInputType(fieldType: ConditionFieldDef["type"]): "text" | "number" | "date" {
|
|
260
|
+
if (fieldType === "number" || fieldType === "currency") return "number"
|
|
261
|
+
if (fieldType === "date") return "date"
|
|
262
|
+
return "text"
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function getInputPlaceholder(fieldType: ConditionFieldDef["type"]): string {
|
|
266
|
+
if (fieldType === "currency") return "Amount"
|
|
267
|
+
if (fieldType === "number") return "Enter number..."
|
|
268
|
+
if (fieldType === "date") return ""
|
|
269
|
+
return "Enter value..."
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
interface ConditionValueInputProps {
|
|
273
|
+
condition: ConditionFilterValue
|
|
274
|
+
fieldDef: ConditionFieldDef
|
|
275
|
+
onValueChange: (event: React.ChangeEvent<HTMLInputElement>) => void
|
|
276
|
+
onSelectValueChange: (value: string) => void
|
|
277
|
+
onMultiSelectValueToggle: (value: string) => void
|
|
278
|
+
onCommit: () => void
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function shouldShowOptionSearch(
|
|
282
|
+
fieldDef: ConditionFieldDef,
|
|
283
|
+
optionCount: number,
|
|
284
|
+
): boolean {
|
|
285
|
+
if (fieldDef.searchable === true) return true
|
|
286
|
+
if (fieldDef.searchable === false) return false
|
|
287
|
+
if (typeof fieldDef.searchable === "object") {
|
|
288
|
+
return optionCount >= (fieldDef.searchable.threshold ?? 8)
|
|
289
|
+
}
|
|
290
|
+
return false
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function SelectConditionValueInput({
|
|
294
|
+
condition,
|
|
295
|
+
fieldDef,
|
|
296
|
+
onSelectValueChange,
|
|
297
|
+
}: Pick<ConditionValueInputProps, "condition" | "fieldDef" | "onSelectValueChange">) {
|
|
298
|
+
const [query, setQuery] = React.useState("")
|
|
299
|
+
const options = normalizeFieldOptions(fieldDef)
|
|
300
|
+
const normalizedQuery = query.trim().toLowerCase()
|
|
301
|
+
const filteredOptions = normalizedQuery
|
|
302
|
+
? options.filter((option) => option.label.toLowerCase().includes(normalizedQuery))
|
|
303
|
+
: options
|
|
304
|
+
const showSearch = shouldShowOptionSearch(fieldDef, options.length)
|
|
305
|
+
|
|
306
|
+
return (
|
|
307
|
+
<Select
|
|
308
|
+
value={typeof condition.value === "string" ? condition.value : ""}
|
|
309
|
+
onValueChange={onSelectValueChange}
|
|
310
|
+
>
|
|
311
|
+
<SelectTrigger className="h-8 w-full" size="sm">
|
|
312
|
+
<SelectValue placeholder="Select value..." />
|
|
313
|
+
</SelectTrigger>
|
|
314
|
+
<SelectContent>
|
|
315
|
+
{showSearch ? (
|
|
316
|
+
<div className="sticky top-0 z-10 border-b border-border bg-popover p-1.5">
|
|
317
|
+
<Input
|
|
318
|
+
value={query}
|
|
319
|
+
onChange={(event) => setQuery(event.target.value)}
|
|
320
|
+
onClick={(event) => event.stopPropagation()}
|
|
321
|
+
onKeyDown={(event) => event.stopPropagation()}
|
|
322
|
+
placeholder="Search options..."
|
|
323
|
+
className="h-7 text-xs"
|
|
324
|
+
/>
|
|
325
|
+
</div>
|
|
326
|
+
) : null}
|
|
327
|
+
{filteredOptions.length > 0 ? (
|
|
328
|
+
filteredOptions.map((option) => (
|
|
329
|
+
<SelectItem key={option.value} value={option.value}>
|
|
330
|
+
{option.label}
|
|
331
|
+
</SelectItem>
|
|
332
|
+
))
|
|
333
|
+
) : (
|
|
334
|
+
<div className="px-2 py-1.5 text-xs text-muted-foreground">No options</div>
|
|
335
|
+
)}
|
|
336
|
+
</SelectContent>
|
|
337
|
+
</Select>
|
|
338
|
+
)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function MultiSelectConditionValueInput({
|
|
342
|
+
condition,
|
|
343
|
+
fieldDef,
|
|
344
|
+
onMultiSelectValueToggle,
|
|
345
|
+
}: Pick<ConditionValueInputProps, "condition" | "fieldDef" | "onMultiSelectValueToggle">) {
|
|
346
|
+
const selectedValues = Array.isArray(condition.value) ? condition.value : []
|
|
347
|
+
const options = normalizeFieldOptions(fieldDef)
|
|
348
|
+
|
|
349
|
+
return (
|
|
350
|
+
<div className="max-h-28 overflow-y-auto rounded-md border border-border bg-background p-1">
|
|
351
|
+
{options.length > 0 ? (
|
|
352
|
+
options.map((option) => {
|
|
353
|
+
const checked = selectedValues.includes(option.value)
|
|
354
|
+
return (
|
|
355
|
+
<button
|
|
356
|
+
key={option.value}
|
|
357
|
+
type="button"
|
|
358
|
+
role="checkbox"
|
|
359
|
+
aria-checked={checked}
|
|
360
|
+
className={cn(
|
|
361
|
+
"flex w-full items-center gap-2 rounded-sm px-2 py-1 text-left text-xs hover:bg-muted",
|
|
362
|
+
checked && "text-brand-purple",
|
|
363
|
+
)}
|
|
364
|
+
onClick={(event) => {
|
|
365
|
+
event.stopPropagation()
|
|
366
|
+
onMultiSelectValueToggle(option.value)
|
|
367
|
+
}}
|
|
368
|
+
>
|
|
369
|
+
<span
|
|
370
|
+
className={cn(
|
|
371
|
+
"flex h-3.5 w-3.5 items-center justify-center rounded-sm border border-border",
|
|
372
|
+
checked && "border-brand-purple bg-brand-purple text-white",
|
|
373
|
+
)}
|
|
374
|
+
aria-hidden="true"
|
|
375
|
+
>
|
|
376
|
+
{checked ? <Check className="h-3 w-3" /> : null}
|
|
377
|
+
</span>
|
|
378
|
+
<span className="min-w-0 flex-1 truncate">{option.label}</span>
|
|
379
|
+
</button>
|
|
380
|
+
)
|
|
381
|
+
})
|
|
382
|
+
) : (
|
|
383
|
+
<div className="px-2 py-1 text-xs text-muted-foreground">No options</div>
|
|
384
|
+
)}
|
|
385
|
+
</div>
|
|
386
|
+
)
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function ScalarConditionValueInput({
|
|
390
|
+
condition,
|
|
391
|
+
fieldDef,
|
|
392
|
+
onValueChange,
|
|
393
|
+
onCommit,
|
|
394
|
+
}: Pick<ConditionValueInputProps, "condition" | "fieldDef" | "onValueChange" | "onCommit">) {
|
|
395
|
+
return (
|
|
396
|
+
<div className="relative flex items-center">
|
|
397
|
+
{fieldDef.type === "currency" ? (
|
|
398
|
+
<span className="pointer-events-none absolute left-2 text-sm text-muted-foreground">
|
|
399
|
+
$
|
|
400
|
+
</span>
|
|
401
|
+
) : null}
|
|
402
|
+
<Input
|
|
403
|
+
type={getInputType(fieldDef.type)}
|
|
404
|
+
value={
|
|
405
|
+
condition.value != null && !Array.isArray(condition.value)
|
|
406
|
+
? String(condition.value)
|
|
407
|
+
: ""
|
|
408
|
+
}
|
|
409
|
+
onChange={onValueChange}
|
|
410
|
+
onClick={(event) => event.stopPropagation()}
|
|
411
|
+
onKeyDown={(event) => {
|
|
412
|
+
event.stopPropagation()
|
|
413
|
+
if (event.key === "Enter") {
|
|
414
|
+
onCommit()
|
|
415
|
+
}
|
|
416
|
+
}}
|
|
417
|
+
placeholder={getInputPlaceholder(fieldDef.type)}
|
|
418
|
+
className={cn("h-8", fieldDef.type === "currency" && "pl-6")}
|
|
419
|
+
/>
|
|
420
|
+
</div>
|
|
421
|
+
)
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function ConditionValueInput(props: ConditionValueInputProps) {
|
|
425
|
+
if (props.fieldDef.type === "select") {
|
|
426
|
+
return <SelectConditionValueInput {...props} />
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if (props.fieldDef.type === "multi_select") {
|
|
430
|
+
return <MultiSelectConditionValueInput {...props} />
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
return <ScalarConditionValueInput {...props} />
|
|
434
|
+
}
|
|
435
|
+
|
|
204
436
|
interface ConditionRowProps {
|
|
205
437
|
condition: ConditionFilterValue
|
|
206
438
|
fields: ConditionFieldDef[]
|
|
@@ -238,7 +470,7 @@ function ConditionRow({
|
|
|
238
470
|
onChange({
|
|
239
471
|
...condition,
|
|
240
472
|
operator: newOp,
|
|
241
|
-
value:
|
|
473
|
+
value: normalizeConditionValue(condition.value, fieldDef, newOp),
|
|
242
474
|
})
|
|
243
475
|
}
|
|
244
476
|
|
|
@@ -249,6 +481,26 @@ function ConditionRow({
|
|
|
249
481
|
})
|
|
250
482
|
}
|
|
251
483
|
|
|
484
|
+
const handleSelectValueChange = (value: string) => {
|
|
485
|
+
onChange({
|
|
486
|
+
...condition,
|
|
487
|
+
value,
|
|
488
|
+
})
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const handleMultiSelectValueToggle = (value: string) => {
|
|
492
|
+
const currentValues = Array.isArray(condition.value) ? condition.value : []
|
|
493
|
+
const nextValues = currentValues.includes(value)
|
|
494
|
+
? currentValues.filter((currentValue) => currentValue !== value)
|
|
495
|
+
: [...currentValues, value]
|
|
496
|
+
|
|
497
|
+
onChange({
|
|
498
|
+
...condition,
|
|
499
|
+
value: nextValues,
|
|
500
|
+
})
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
|
|
252
504
|
return (
|
|
253
505
|
<div
|
|
254
506
|
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"
|
|
@@ -299,41 +551,14 @@ function ConditionRow({
|
|
|
299
551
|
No value needed
|
|
300
552
|
</div>
|
|
301
553
|
) : (
|
|
302
|
-
<
|
|
303
|
-
{
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
fieldDef.type === "number" || fieldDef.type === "currency"
|
|
311
|
-
? "number"
|
|
312
|
-
: fieldDef.type === "date"
|
|
313
|
-
? "date"
|
|
314
|
-
: "text"
|
|
315
|
-
}
|
|
316
|
-
value={condition.value != null ? String(condition.value) : ""}
|
|
317
|
-
onChange={handleValueChange}
|
|
318
|
-
onClick={(event) => event.stopPropagation()}
|
|
319
|
-
onKeyDown={(event) => {
|
|
320
|
-
event.stopPropagation()
|
|
321
|
-
if (event.key === "Enter") {
|
|
322
|
-
onCommit()
|
|
323
|
-
}
|
|
324
|
-
}}
|
|
325
|
-
placeholder={
|
|
326
|
-
fieldDef.type === "currency"
|
|
327
|
-
? "Amount"
|
|
328
|
-
: fieldDef.type === "number"
|
|
329
|
-
? "Enter number..."
|
|
330
|
-
: fieldDef.type === "date"
|
|
331
|
-
? ""
|
|
332
|
-
: "Enter value..."
|
|
333
|
-
}
|
|
334
|
-
className={cn("h-8", fieldDef.type === "currency" && "pl-6")}
|
|
335
|
-
/>
|
|
336
|
-
</div>
|
|
554
|
+
<ConditionValueInput
|
|
555
|
+
condition={condition}
|
|
556
|
+
fieldDef={fieldDef}
|
|
557
|
+
onValueChange={handleValueChange}
|
|
558
|
+
onSelectValueChange={handleSelectValueChange}
|
|
559
|
+
onMultiSelectValueToggle={handleMultiSelectValueToggle}
|
|
560
|
+
onCommit={onCommit}
|
|
561
|
+
/>
|
|
337
562
|
)}
|
|
338
563
|
|
|
339
564
|
<div className="flex items-center gap-1">
|
|
@@ -160,7 +160,7 @@ export function DataTableFilter({
|
|
|
160
160
|
<ListFilter className="h-3.5 w-3.5" />
|
|
161
161
|
Filter
|
|
162
162
|
{activeCount > 0 ? (
|
|
163
|
-
<span className="rounded bg-muted px-1.5 py-0 text-[10px] font-semibold
|
|
163
|
+
<span className="rounded bg-muted px-1.5 py-0 text-[10px] font-semibold">
|
|
164
164
|
{activeCount}
|
|
165
165
|
</span>
|
|
166
166
|
) : null}
|