@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.
- package/dist/components/data-table-condition-filter.d.ts +37 -0
- package/dist/components/data-table-condition-filter.js +424 -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 +91 -20
- 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/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 +423 -0
- package/src/components/__tests__/data-table-filter-presets.test.tsx +1 -1
- package/src/components/__tests__/data-table-filter.test.tsx +291 -3
- package/src/components/data-table-condition-filter.tsx +541 -0
- package/src/components/data-table-filter.tsx +101 -19
- 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
|
@@ -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
|
-
|
|
98
|
-
|
|
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
|
· Last activity {lastActivityTime ?? timelineEvents[0]?.time ?? ''}
|