@handled-ai/design-system 0.16.0 → 0.16.2

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.
Files changed (30) hide show
  1. package/dist/components/data-table-condition-filter.d.ts +37 -0
  2. package/dist/components/data-table-condition-filter.js +424 -0
  3. package/dist/components/data-table-condition-filter.js.map +1 -0
  4. package/dist/components/data-table-filter.d.ts +12 -1
  5. package/dist/components/data-table-filter.js +91 -20
  6. package/dist/components/data-table-filter.js.map +1 -1
  7. package/dist/components/data-table-toolbar.d.ts +1 -0
  8. package/dist/components/data-table.d.ts +1 -0
  9. package/dist/index.d.ts +1 -0
  10. package/dist/index.js +1 -0
  11. package/dist/index.js.map +1 -1
  12. package/dist/prototype/index.d.ts +1 -0
  13. package/dist/prototype/prototype-accounts-view.d.ts +1 -0
  14. package/dist/prototype/prototype-admin-view.d.ts +1 -0
  15. package/dist/prototype/prototype-config.d.ts +1 -0
  16. package/dist/prototype/prototype-inbox-view.d.ts +4 -1
  17. package/dist/prototype/prototype-inbox-view.js +6 -1
  18. package/dist/prototype/prototype-inbox-view.js.map +1 -1
  19. package/dist/prototype/prototype-insights-view.d.ts +1 -0
  20. package/dist/prototype/prototype-shell.d.ts +1 -0
  21. package/package.json +1 -1
  22. package/src/components/__tests__/contact-list.test.tsx +1 -1
  23. package/src/components/__tests__/data-table-condition-filter.test.tsx +423 -0
  24. package/src/components/__tests__/data-table-filter-presets.test.tsx +1 -1
  25. package/src/components/__tests__/data-table-filter.test.tsx +291 -3
  26. package/src/components/data-table-condition-filter.tsx +541 -0
  27. package/src/components/data-table-filter.tsx +101 -19
  28. package/src/index.ts +1 -0
  29. package/src/prototype/__tests__/detail-view-attention.test.tsx +101 -0
  30. package/src/prototype/prototype-inbox-view.tsx +8 -0
@@ -0,0 +1,541 @@
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 getConditionsSignature(conditions: ConditionFilterValue[]): string {
175
+ return conditions
176
+ .map((condition) => `${condition.id}:${condition.field}:${condition.operator}:${String(condition.value)}`)
177
+ .join(";")
178
+ }
179
+
180
+ function getFieldsSignature(fields: ConditionFieldDef[]): string {
181
+ return fields
182
+ .map((field) => `${field.id}:${field.type}:${field.label}:${(field.operators ?? []).join("|")}`)
183
+ .join(";")
184
+ }
185
+
186
+ function getCommittedConditions(
187
+ drafts: ConditionFilterValue[],
188
+ fields: ConditionFieldDef[],
189
+ ): ConditionFilterValue[] {
190
+ return drafts
191
+ .map((condition) => normalizeCondition(condition, fields))
192
+ .filter((condition) => isCompleteCondition(condition, fields))
193
+ }
194
+
195
+ function getFieldIcon(type: ConditionFieldDef["type"]) {
196
+ if (type === "currency") return DollarSign
197
+ if (type === "number") return Hash
198
+ if (type === "date") return CalendarDays
199
+ return Type
200
+ }
201
+
202
+ // ── Condition Row ──────────────────────────────────────────────
203
+
204
+ interface ConditionRowProps {
205
+ condition: ConditionFilterValue
206
+ fields: ConditionFieldDef[]
207
+ index: number
208
+ onChange: (updated: ConditionFilterValue) => void
209
+ onRemove: () => void
210
+ onCommit: () => void
211
+ }
212
+
213
+ function ConditionRow({
214
+ condition,
215
+ fields,
216
+ index,
217
+ onChange,
218
+ onRemove,
219
+ onCommit,
220
+ }: ConditionRowProps) {
221
+ const fieldDef = fields.find((f) => f.id === condition.field) ?? fields[0]
222
+ const operators = getOperators(fieldDef)
223
+ const isUnary = isUnaryOperator(condition.operator)
224
+ const FieldIcon = getFieldIcon(fieldDef.type)
225
+
226
+ const handleFieldChange = (newFieldId: string) => {
227
+ const newFieldDef = fields.find((f) => f.id === newFieldId) ?? fields[0]
228
+ if (!newFieldDef) return
229
+ onChange({
230
+ ...condition,
231
+ field: newFieldDef.id,
232
+ operator: getDefaultOperator(newFieldDef),
233
+ value: null,
234
+ })
235
+ }
236
+
237
+ const handleOperatorChange = (newOp: ConditionOperator) => {
238
+ onChange({
239
+ ...condition,
240
+ operator: newOp,
241
+ value: isUnaryOperator(newOp) ? null : condition.value,
242
+ })
243
+ }
244
+
245
+ const handleValueChange = (event: React.ChangeEvent<HTMLInputElement>) => {
246
+ onChange({
247
+ ...condition,
248
+ value: parseConditionValue(event.target.value, fieldDef.type),
249
+ })
250
+ }
251
+
252
+ return (
253
+ <div
254
+ 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"
255
+ data-slot="condition-row"
256
+ >
257
+ <div className="text-xs font-medium text-muted-foreground">
258
+ {index === 0 ? "Where" : "And"}
259
+ </div>
260
+
261
+ <Select value={condition.field} onValueChange={handleFieldChange}>
262
+ <SelectTrigger className="h-8 w-full justify-start gap-2" size="sm">
263
+ <FieldIcon className="h-3.5 w-3.5 text-muted-foreground" />
264
+ <SelectValue placeholder={fieldDef.label} />
265
+ </SelectTrigger>
266
+ <SelectContent>
267
+ {fields.map((field) => {
268
+ const Icon = getFieldIcon(field.type)
269
+ return (
270
+ <SelectItem key={field.id} value={field.id}>
271
+ <span className="inline-flex items-center gap-2">
272
+ <Icon className="h-3.5 w-3.5 text-muted-foreground" />
273
+ {field.label}
274
+ </span>
275
+ </SelectItem>
276
+ )
277
+ })}
278
+ </SelectContent>
279
+ </Select>
280
+
281
+ <Select
282
+ value={condition.operator}
283
+ onValueChange={(value) => handleOperatorChange(value as ConditionOperator)}
284
+ >
285
+ <SelectTrigger className="h-8 w-full" size="sm">
286
+ <SelectValue placeholder="Operator" />
287
+ </SelectTrigger>
288
+ <SelectContent>
289
+ {operators.map((op) => (
290
+ <SelectItem key={op} value={op}>
291
+ {OPERATOR_LABELS[op]}
292
+ </SelectItem>
293
+ ))}
294
+ </SelectContent>
295
+ </Select>
296
+
297
+ {isUnary ? (
298
+ <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">
299
+ No value needed
300
+ </div>
301
+ ) : (
302
+ <div className="relative flex items-center">
303
+ {fieldDef.type === "currency" ? (
304
+ <span className="pointer-events-none absolute left-2 text-sm text-muted-foreground">
305
+ $
306
+ </span>
307
+ ) : null}
308
+ <Input
309
+ type={
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>
337
+ )}
338
+
339
+ <div className="flex items-center gap-1">
340
+ <Button
341
+ type="button"
342
+ variant="ghost"
343
+ size="icon"
344
+ className="h-8 w-8 text-muted-foreground"
345
+ aria-label="Toggle condition visibility"
346
+ onClick={(event) => event.preventDefault()}
347
+ >
348
+ <Eye className="size-4" />
349
+ </Button>
350
+ <Button
351
+ type="button"
352
+ variant="ghost"
353
+ size="icon"
354
+ className="h-8 w-8 text-muted-foreground"
355
+ aria-label="More condition actions"
356
+ onClick={(event) => event.preventDefault()}
357
+ >
358
+ <MoreHorizontal className="size-4" />
359
+ </Button>
360
+ <Button
361
+ type="button"
362
+ variant="ghost"
363
+ size="icon"
364
+ className="h-8 w-8 text-muted-foreground hover:text-destructive"
365
+ onClick={onRemove}
366
+ aria-label="Remove condition"
367
+ >
368
+ <Trash2 className="size-4" />
369
+ </Button>
370
+ </div>
371
+ </div>
372
+ )
373
+ }
374
+
375
+ // ── Main Component ─────────────────────────────────────────────
376
+
377
+ function DataTableConditionFilter({
378
+ fields,
379
+ conditions,
380
+ onConditionsChange,
381
+ className,
382
+ }: DataTableConditionFilterProps) {
383
+ const [drafts, setDrafts] = React.useState<ConditionFilterValue[]>(() =>
384
+ conditions.map((condition) => normalizeCondition(condition, fields)),
385
+ )
386
+
387
+ const fieldsSignature = React.useMemo(() => getFieldsSignature(fields), [fields])
388
+ const conditionsSignature = React.useMemo(() => getConditionsSignature(conditions), [conditions])
389
+ const fieldsRef = React.useRef(fields)
390
+
391
+ React.useEffect(() => {
392
+ setDrafts(conditions.map((condition) => normalizeCondition(condition, fieldsRef.current)))
393
+ }, [conditionsSignature])
394
+
395
+ React.useEffect(() => {
396
+ if (fieldsRef.current !== fields) {
397
+ fieldsRef.current = fields
398
+ setDrafts((current) => current.map((condition) => normalizeCondition(condition, fields)))
399
+ }
400
+ // Depend on a structural signature so inline-but-equivalent field arrays do
401
+ // not wipe in-progress drafts before Apply.
402
+ }, [fieldsSignature, fields])
403
+
404
+ const commitDrafts = React.useCallback(
405
+ (nextDrafts: ConditionFilterValue[] = drafts) => {
406
+ onConditionsChange(getCommittedConditions(nextDrafts, fields))
407
+ },
408
+ [drafts, fields, onConditionsChange],
409
+ )
410
+
411
+ const handleAdd = () => {
412
+ const firstField = fields[0]
413
+ if (!firstField) return
414
+ const committedDrafts = getCommittedConditions(drafts, fields)
415
+ const nextDrafts = [...committedDrafts, createDraftCondition(firstField)]
416
+ setDrafts(nextDrafts)
417
+ onConditionsChange(committedDrafts)
418
+ }
419
+
420
+ const handleUpdate = (index: number, updated: ConditionFilterValue) => {
421
+ setDrafts((current) => {
422
+ const next = [...current]
423
+ next[index] = normalizeCondition(updated, fields)
424
+ return next
425
+ })
426
+ }
427
+
428
+ const handleRemove = (index: number) => {
429
+ setDrafts((current) => {
430
+ const next = current.filter((_, currentIndex) => currentIndex !== index)
431
+ commitDrafts(next)
432
+ return next
433
+ })
434
+ }
435
+
436
+ const handleClear = () => {
437
+ setDrafts([])
438
+ onConditionsChange([])
439
+ }
440
+
441
+ const hasAppliedConditions = conditions.length > 0
442
+ const hasDrafts = drafts.length > 0
443
+
444
+ return (
445
+ <div
446
+ className={cn(
447
+ "w-[min(760px,calc(100vw-2rem))] rounded-xl border border-border bg-background p-3 text-foreground shadow-xl",
448
+ className,
449
+ )}
450
+ data-slot="condition-filter"
451
+ onClick={(event) => event.stopPropagation()}
452
+ onKeyDown={(event) => {
453
+ if (event.key !== "Escape") {
454
+ event.stopPropagation()
455
+ }
456
+ }}
457
+ >
458
+ <div className="mb-3 flex items-center justify-between gap-3 border-b border-border/70 pb-3">
459
+ <div>
460
+ <div className="text-sm font-semibold">Filter builder</div>
461
+ <div className="text-xs text-muted-foreground">
462
+ Build field, operator, and value conditions.
463
+ </div>
464
+ </div>
465
+ <Button
466
+ type="button"
467
+ variant="ghost"
468
+ size="sm"
469
+ className="h-8 text-xs text-destructive hover:text-destructive"
470
+ onClick={handleClear}
471
+ disabled={!hasAppliedConditions && !hasDrafts}
472
+ >
473
+ Clear filters
474
+ </Button>
475
+ </div>
476
+
477
+ <div className="flex flex-col gap-2">
478
+ {drafts.map((condition, index) => (
479
+ <ConditionRow
480
+ key={condition.id}
481
+ condition={condition}
482
+ fields={fields}
483
+ index={index}
484
+ onChange={(updated) => handleUpdate(index, updated)}
485
+ onRemove={() => handleRemove(index)}
486
+ onCommit={() => commitDrafts()}
487
+ />
488
+ ))}
489
+ </div>
490
+
491
+ {!hasDrafts ? (
492
+ <div className="rounded-lg border border-dashed border-border/80 bg-muted/20 px-3 py-5 text-center text-xs text-muted-foreground">
493
+ No builder filters yet. Add a filter to start a condition row.
494
+ </div>
495
+ ) : null}
496
+
497
+ <div className="mt-3 flex flex-wrap items-center justify-between gap-2 border-t border-border/70 pt-3">
498
+ <div className="flex items-center gap-2">
499
+ <Button
500
+ type="button"
501
+ variant="ghost"
502
+ size="sm"
503
+ className="h-8 text-xs text-muted-foreground"
504
+ onClick={handleAdd}
505
+ >
506
+ <Plus className="mr-1 size-3.5" />
507
+ Add filter
508
+ </Button>
509
+ <Button
510
+ type="button"
511
+ variant="ghost"
512
+ size="sm"
513
+ className="h-8 text-xs text-muted-foreground opacity-60"
514
+ disabled
515
+ aria-disabled="true"
516
+ >
517
+ <Plus className="mr-1 size-3.5" />
518
+ Add filter group
519
+ </Button>
520
+ </div>
521
+ <Button
522
+ type="button"
523
+ size="sm"
524
+ className="h-8 text-xs"
525
+ onClick={() => commitDrafts()}
526
+ >
527
+ Apply
528
+ </Button>
529
+ </div>
530
+ </div>
531
+ )
532
+ }
533
+
534
+ export {
535
+ DataTableConditionFilter,
536
+ OPERATOR_LABELS,
537
+ DEFAULT_OPERATORS,
538
+ generateConditionId,
539
+ getOperators,
540
+ }
541
+ export type { DataTableConditionFilterProps }