@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
@@ -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()
@@ -88,29 +111,13 @@ export function DataTableFilter({
88
111
  )
89
112
 
90
113
  const activeCount = React.useMemo(() => {
91
- // Count user-selected filters
92
114
  const userCount = Object.values(selectedFilters).reduce(
93
115
  (count, selected) => count + selected.length,
94
116
  0
95
117
  )
96
118
 
97
- // Count active preset filters (those that are in presetFilters AND currently active in selectedFilters)
98
- let presetCount = 0
99
- if (presetFilters) {
100
- for (const [categoryId, presetValues] of Object.entries(presetFilters)) {
101
- for (const value of presetValues) {
102
- // Only count if the preset is active (in selectedFilters) but NOT already counted as a user filter
103
- if (selectedFilters[categoryId]?.includes(value)) {
104
- // Already counted in userCount, skip
105
- } else {
106
- // Not in selectedFilters — it's an inactive preset, don't count
107
- }
108
- }
109
- }
110
- }
111
-
112
- return userCount + presetCount
113
- }, [selectedFilters, presetFilters])
119
+ return userCount + conditionFilters.length
120
+ }, [selectedFilters, conditionFilters.length])
114
121
 
115
122
  /** Collect all preset chips to render */
116
123
  const presetChips = React.useMemo(() => {
@@ -170,6 +177,31 @@ export function DataTableFilter({
170
177
 
171
178
  <div className="max-h-[320px] overflow-y-auto p-1">
172
179
  {visibleCategories.map((category) => {
180
+ const filterType = category.type ?? "multi"
181
+
182
+ /* ── Boolean toggle ─────────────────────────────────── */
183
+ if (filterType === "boolean") {
184
+ const active = selectedFilters[category.id]?.includes("true") ?? false
185
+ return (
186
+ <DropdownMenuItem
187
+ key={category.id}
188
+ className={cn(
189
+ "cursor-pointer py-1.5 text-xs",
190
+ active && "text-brand-purple"
191
+ )}
192
+ onSelect={(event) => {
193
+ event.preventDefault()
194
+ onToggleFilter(category.id, "true")
195
+ }}
196
+ >
197
+ <category.icon className="mr-2 h-3.5 w-3.5" />
198
+ {category.label}
199
+ {active ? <Check className="ml-auto h-4 w-4" /> : null}
200
+ </DropdownMenuItem>
201
+ )
202
+ }
203
+
204
+ /* ── Sub-menu (single / multi) ──────────────────────── */
173
205
  const subQuery = (subQueries[category.id] ?? "").trim().toLowerCase()
174
206
  const filteredOptions = subQuery
175
207
  ? category.options.filter((opt) =>
@@ -241,6 +273,8 @@ export function DataTableFilter({
241
273
  <span className="text-brand-purple text-[10px] font-semibold">
242
274
  {presetLabel}
243
275
  </span>
276
+ ) : filterType === "single" ? (
277
+ <span className="h-1.5 w-1.5 rounded-full bg-current" />
244
278
  ) : (
245
279
  <span className="text-[10px] font-semibold text-brand-purple">
246
280
  Applied
@@ -266,6 +300,54 @@ export function DataTableFilter({
266
300
  </div>
267
301
  ) : null}
268
302
  </div>
303
+
304
+ {hasConditionBuilder ? (
305
+ <div className="border-t border-border p-1">
306
+ <PopoverPrimitive.Root
307
+ open={conditionBuilderOpen}
308
+ onOpenChange={setConditionBuilderOpen}
309
+ >
310
+ <PopoverPrimitive.Trigger asChild>
311
+ <DropdownMenuItem
312
+ className="cursor-pointer py-1.5 text-xs"
313
+ onSelect={(event) => {
314
+ event.preventDefault()
315
+ setConditionBuilderOpen(true)
316
+ }}
317
+ >
318
+ <Plus className="mr-2 h-3.5 w-3.5 text-muted-foreground" />
319
+ {conditionBuilderLabel}
320
+ {conditionFilters.length > 0 ? (
321
+ <span className="ml-auto rounded bg-muted px-1.5 py-0 text-[10px] font-semibold">
322
+ {conditionFilters.length}
323
+ </span>
324
+ ) : null}
325
+ </DropdownMenuItem>
326
+ </PopoverPrimitive.Trigger>
327
+ <PopoverPrimitive.Portal>
328
+ <PopoverPrimitive.Content
329
+ align="start"
330
+ side="right"
331
+ sideOffset={8}
332
+ className="z-50 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"
333
+ onEscapeKeyDown={() => setConditionBuilderOpen(false)}
334
+ onInteractOutside={(event) => {
335
+ const target = event.target as HTMLElement | null
336
+ if (target?.closest('[data-slot="dropdown-menu-content"]')) {
337
+ event.preventDefault()
338
+ }
339
+ }}
340
+ >
341
+ <DataTableConditionFilter
342
+ fields={conditionFields}
343
+ conditions={conditionFilters}
344
+ onConditionsChange={onConditionFiltersChange ?? (() => {})}
345
+ />
346
+ </PopoverPrimitive.Content>
347
+ </PopoverPrimitive.Portal>
348
+ </PopoverPrimitive.Root>
349
+ </div>
350
+ ) : null}
269
351
  </DropdownMenuContent>
270
352
  </DropdownMenu>
271
353
  )
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"
@@ -0,0 +1,101 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import React from "react";
3
+ import { render, fireEvent } from "@testing-library/react";
4
+ import { DetailView, type DetailViewProps } from "../prototype-inbox-view";
5
+ import type { QueueItem, SignalScoreData } from "../prototype-config";
6
+ import type { TimelineEvent } from "../../components/timeline-activity";
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Helpers
10
+ // ---------------------------------------------------------------------------
11
+
12
+ const baseItem: QueueItem = {
13
+ id: "1",
14
+ title: "Test Signal",
15
+ details: "Some details",
16
+ statusColor: "green",
17
+ time: "2h ago",
18
+ company: "Acme Inc",
19
+ tag1: "renewal",
20
+ };
21
+
22
+ function makeSignalScore(): SignalScoreData {
23
+ return {
24
+ score: 75,
25
+ factors: [
26
+ { key: "trigger", label: "Trigger strength", score: 70, why: "Strong signal" },
27
+ ],
28
+ whyNow: "Strong signals detected.",
29
+ evidence: ["Evidence line 1"],
30
+ confidence: 80,
31
+ };
32
+ }
33
+
34
+ const sampleTimelineEvents: TimelineEvent[] = [
35
+ { id: "t1", icon: React.createElement("span", null, "📧"), title: "Email sent", time: "1h ago" },
36
+ { id: "t2", icon: React.createElement("span", null, "📞"), title: "Call logged", time: "3h ago" },
37
+ ];
38
+
39
+ function baseProps(overrides: Partial<DetailViewProps> = {}): DetailViewProps {
40
+ return {
41
+ item: baseItem,
42
+ sections: { signalBrief: true, suggestedActions: false, timeline: true },
43
+ getSignalScore: () => makeSignalScore(),
44
+ buildSuggestedActions: () => [],
45
+ buildSourceItems: () => [],
46
+ getTimelineEvents: () => sampleTimelineEvents,
47
+ accountContacts: [],
48
+ emailSignature: "",
49
+ iconMap: {},
50
+ ...overrides,
51
+ };
52
+ }
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Tests
56
+ // ---------------------------------------------------------------------------
57
+
58
+ describe("DetailView attentionCount", () => {
59
+ it("renders attention pill with count when attentionCount > 0 and timeline collapsed", () => {
60
+ const props = baseProps({ attentionCount: 3 });
61
+ const { container } = render(<DetailView {...props} />);
62
+ const pill = container.querySelector("span.inline-flex");
63
+ expect(pill).not.toBeNull();
64
+ expect(pill!.textContent).toContain("3");
65
+ expect(pill!.textContent).toContain("new");
66
+ expect(pill!.className).toContain("bg-destructive/10");
67
+ });
68
+
69
+ it("does NOT render pill when attentionCount is 0", () => {
70
+ const props = baseProps({ attentionCount: 0 });
71
+ const { container } = render(<DetailView {...props} />);
72
+ const pill = container.querySelector("span.inline-flex");
73
+ expect(pill).toBeNull();
74
+ });
75
+
76
+ it("does NOT render pill when attentionCount is undefined", () => {
77
+ const props = baseProps({ attentionCount: undefined });
78
+ const { container } = render(<DetailView {...props} />);
79
+ const pill = container.querySelector("span.inline-flex");
80
+ expect(pill).toBeNull();
81
+ });
82
+
83
+ it("does NOT render pill when timeline is expanded", () => {
84
+ const props = baseProps({ attentionCount: 5 });
85
+ const { container } = render(<DetailView {...props} />);
86
+
87
+ // Pill should be visible while collapsed
88
+ let pill = container.querySelector("span.inline-flex");
89
+ expect(pill).not.toBeNull();
90
+ expect(pill!.textContent).toContain("5");
91
+
92
+ // Click the timeline header button to expand
93
+ const timelineButton = container.querySelector("button.group\\/timeline") as HTMLElement;
94
+ expect(timelineButton).not.toBeNull();
95
+ fireEvent.click(timelineButton);
96
+
97
+ // After expanding, pill should be gone
98
+ pill = container.querySelector("span.inline-flex");
99
+ expect(pill).toBeNull();
100
+ });
101
+ });
@@ -142,6 +142,8 @@ export interface DetailViewProps {
142
142
  approveButtonIconUrl?: string
143
143
  opportunityPreview?: OpportunityPreview
144
144
  onRequestApproval?: () => Promise<void>
145
+ /** Number of important/attention-worthy events to highlight on the collapsed timeline header. */
146
+ attentionCount?: number
145
147
  }
146
148
 
147
149
  export function DetailView({
@@ -172,6 +174,7 @@ export function DetailView({
172
174
  approveButtonIconUrl,
173
175
  opportunityPreview,
174
176
  onRequestApproval,
177
+ attentionCount,
175
178
  }: DetailViewProps) {
176
179
  const [evidenceExpanded, setEvidenceExpanded] = React.useState(false)
177
180
  const [showTimeline, setShowTimeline] = React.useState(false)
@@ -422,6 +425,11 @@ export function DetailView({
422
425
  >
423
426
  <div className="flex items-center gap-2">
424
427
  <h3 className="text-xs font-bold text-muted-foreground uppercase tracking-wider group-hover/timeline:text-foreground transition-colors">Activity timeline</h3>
428
+ {!showTimeline && attentionCount != null && attentionCount > 0 && (
429
+ <span className="inline-flex items-center gap-1 rounded-full bg-destructive/10 px-1.5 py-0.5 text-[10px] font-semibold text-destructive border border-destructive/20">
430
+ {attentionCount} new
431
+ </span>
432
+ )}
425
433
  {!showTimeline && (lastActivityTime || (timelineEvents.length > 0 && timelineEvents[0].time)) && (
426
434
  <span className="text-[11px] text-muted-foreground/60">
427
435
  &middot; Last activity {lastActivityTime ?? timelineEvents[0]?.time ?? ''}