@handled-ai/design-system 0.15.1 → 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/collapsible-section.d.ts +20 -0
- package/dist/components/collapsible-section.js +48 -0
- package/dist/components/collapsible-section.js.map +1 -0
- package/dist/components/contact-list.d.ts +3 -1
- package/dist/components/contact-list.js +20 -3
- package/dist/components/contact-list.js.map +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 +19 -2
- package/dist/components/data-table-filter.js +160 -13
- 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/entity-panel.js +1 -1
- package/dist/components/entity-panel.js.map +1 -1
- package/dist/components/tabs.d.ts +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -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__/collapsible-section.test.tsx +143 -0
- package/src/components/__tests__/contact-list.test.tsx +116 -0
- package/src/components/__tests__/data-table-condition-filter.test.tsx +397 -0
- package/src/components/__tests__/data-table-filter-presets.test.tsx +209 -0
- package/src/components/__tests__/data-table-filter.test.tsx +270 -3
- package/src/components/__tests__/entity-metadata-grid.test.tsx +25 -0
- package/src/components/__tests__/virtualized-data-table.test.tsx +0 -1
- package/src/components/collapsible-section.tsx +62 -0
- package/src/components/contact-list.tsx +22 -3
- package/src/components/data-table-condition-filter.tsx +513 -0
- package/src/components/data-table-filter.tsx +201 -13
- package/src/components/entity-panel.tsx +1 -1
- package/src/index.ts +2 -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 {
|
|
@@ -34,13 +43,27 @@ function getOptionLabel(option: string | FilterOption): string {
|
|
|
34
43
|
return typeof option === "string" ? option : option.label
|
|
35
44
|
}
|
|
36
45
|
|
|
37
|
-
interface DataTableFilterProps {
|
|
46
|
+
export interface DataTableFilterProps {
|
|
38
47
|
categories: DataTableFilterCategory[]
|
|
39
48
|
selectedFilters: Record<string, string[]>
|
|
40
49
|
onToggleFilter: (categoryId: string, option: string) => void
|
|
41
50
|
className?: string
|
|
42
51
|
/** Minimum number of options before showing the sub-menu search input. Defaults to 8. */
|
|
43
52
|
optionSearchThreshold?: number
|
|
53
|
+
/** Filters applied by default. Shown as distinct chips that can be toggled off but not dismissed. */
|
|
54
|
+
presetFilters?: Record<string, string[]>
|
|
55
|
+
/** Callback when a preset filter is toggled on/off. */
|
|
56
|
+
onTogglePreset?: (categoryId: string, option: string) => void
|
|
57
|
+
/** Label shown on preset chips to distinguish from user-applied filters. Default: "Default". */
|
|
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
|
|
44
67
|
}
|
|
45
68
|
|
|
46
69
|
export function DataTableFilter({
|
|
@@ -49,9 +72,18 @@ export function DataTableFilter({
|
|
|
49
72
|
onToggleFilter,
|
|
50
73
|
className,
|
|
51
74
|
optionSearchThreshold = 8,
|
|
75
|
+
presetFilters,
|
|
76
|
+
onTogglePreset,
|
|
77
|
+
presetLabel = "Default",
|
|
78
|
+
conditionFields = [],
|
|
79
|
+
conditionFilters = [],
|
|
80
|
+
onConditionFiltersChange,
|
|
81
|
+
conditionBuilderLabel = "Add filter",
|
|
52
82
|
}: DataTableFilterProps) {
|
|
53
83
|
const [query, setQuery] = React.useState("")
|
|
54
84
|
const [subQueries, setSubQueries] = React.useState<Record<string, string>>({})
|
|
85
|
+
const [conditionBuilderOpen, setConditionBuilderOpen] = React.useState(false)
|
|
86
|
+
const hasConditionBuilder = conditionFields.length > 0
|
|
55
87
|
|
|
56
88
|
const visibleCategories = React.useMemo(() => {
|
|
57
89
|
const normalized = query.trim().toLowerCase()
|
|
@@ -70,16 +102,61 @@ export function DataTableFilter({
|
|
|
70
102
|
})
|
|
71
103
|
}, [categories, query])
|
|
72
104
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
[selectedFilters]
|
|
105
|
+
/** Check if a specific option is a preset filter */
|
|
106
|
+
const isPresetOption = React.useCallback(
|
|
107
|
+
(categoryId: string, value: string): boolean => {
|
|
108
|
+
return presetFilters?.[categoryId]?.includes(value) ?? false
|
|
109
|
+
},
|
|
110
|
+
[presetFilters]
|
|
80
111
|
)
|
|
81
112
|
|
|
82
|
-
|
|
113
|
+
const activeCount = React.useMemo(() => {
|
|
114
|
+
// Count user-selected filters
|
|
115
|
+
const userCount = Object.values(selectedFilters).reduce(
|
|
116
|
+
(count, selected) => count + selected.length,
|
|
117
|
+
0
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
// Count active preset filters (those that are in presetFilters AND currently active in selectedFilters)
|
|
121
|
+
const presetCount = 0
|
|
122
|
+
if (presetFilters) {
|
|
123
|
+
for (const [categoryId, presetValues] of Object.entries(presetFilters)) {
|
|
124
|
+
for (const value of presetValues) {
|
|
125
|
+
// Only count if the preset is active (in selectedFilters) but NOT already counted as a user filter
|
|
126
|
+
if (selectedFilters[categoryId]?.includes(value)) {
|
|
127
|
+
// Already counted in userCount, skip
|
|
128
|
+
} else {
|
|
129
|
+
// Not in selectedFilters — it's an inactive preset, don't count
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return userCount + presetCount + conditionFilters.length
|
|
136
|
+
}, [selectedFilters, presetFilters, conditionFilters.length])
|
|
137
|
+
|
|
138
|
+
/** Collect all preset chips to render */
|
|
139
|
+
const presetChips = React.useMemo(() => {
|
|
140
|
+
if (!presetFilters) return []
|
|
141
|
+
|
|
142
|
+
const chips: { categoryId: string; value: string; label: string; active: boolean }[] = []
|
|
143
|
+
|
|
144
|
+
for (const [categoryId, values] of Object.entries(presetFilters)) {
|
|
145
|
+
const category = categories.find((c) => c.id === categoryId)
|
|
146
|
+
for (const value of values) {
|
|
147
|
+
const option = category?.options.find(
|
|
148
|
+
(opt) => getOptionValue(opt) === value
|
|
149
|
+
)
|
|
150
|
+
const label = option ? getOptionLabel(option) : value
|
|
151
|
+
const active = selectedFilters[categoryId]?.includes(value) ?? false
|
|
152
|
+
chips.push({ categoryId, value, label, active })
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return chips
|
|
157
|
+
}, [presetFilters, categories, selectedFilters])
|
|
158
|
+
|
|
159
|
+
const triggerButton = (
|
|
83
160
|
<DropdownMenu>
|
|
84
161
|
<DropdownMenuTrigger asChild>
|
|
85
162
|
<Button
|
|
@@ -116,6 +193,31 @@ export function DataTableFilter({
|
|
|
116
193
|
|
|
117
194
|
<div className="max-h-[320px] overflow-y-auto p-1">
|
|
118
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) ──────────────────────── */
|
|
119
221
|
const subQuery = (subQueries[category.id] ?? "").trim().toLowerCase()
|
|
120
222
|
const filteredOptions = subQuery
|
|
121
223
|
? category.options.filter((opt) =>
|
|
@@ -171,6 +273,7 @@ export function DataTableFilter({
|
|
|
171
273
|
const value = getOptionValue(option)
|
|
172
274
|
const label = getOptionLabel(option)
|
|
173
275
|
const selected = selectedFilters[category.id]?.includes(value) ?? false
|
|
276
|
+
const isPreset = isPresetOption(category.id, value)
|
|
174
277
|
return (
|
|
175
278
|
<DropdownMenuItem
|
|
176
279
|
key={value}
|
|
@@ -182,9 +285,17 @@ export function DataTableFilter({
|
|
|
182
285
|
>
|
|
183
286
|
{label}
|
|
184
287
|
{selected ? (
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
288
|
+
isPreset ? (
|
|
289
|
+
<span className="text-brand-purple text-[10px] font-semibold">
|
|
290
|
+
{presetLabel}
|
|
291
|
+
</span>
|
|
292
|
+
) : filterType === "single" ? (
|
|
293
|
+
<span className="h-1.5 w-1.5 rounded-full bg-current" />
|
|
294
|
+
) : (
|
|
295
|
+
<span className="text-[10px] font-semibold text-brand-purple">
|
|
296
|
+
Applied
|
|
297
|
+
</span>
|
|
298
|
+
)
|
|
188
299
|
) : null}
|
|
189
300
|
</DropdownMenuItem>
|
|
190
301
|
)
|
|
@@ -205,7 +316,84 @@ export function DataTableFilter({
|
|
|
205
316
|
</div>
|
|
206
317
|
) : null}
|
|
207
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}
|
|
208
367
|
</DropdownMenuContent>
|
|
209
368
|
</DropdownMenu>
|
|
210
369
|
)
|
|
370
|
+
|
|
371
|
+
// If there are preset chips, wrap trigger + chips together
|
|
372
|
+
if (presetChips.length > 0) {
|
|
373
|
+
return (
|
|
374
|
+
<div className="flex flex-wrap items-center gap-1.5">
|
|
375
|
+
{triggerButton}
|
|
376
|
+
{presetChips.map((chip) => (
|
|
377
|
+
<button
|
|
378
|
+
key={`${chip.categoryId}-${chip.value}`}
|
|
379
|
+
type="button"
|
|
380
|
+
onClick={() => onTogglePreset?.(chip.categoryId, chip.value)}
|
|
381
|
+
className={cn(
|
|
382
|
+
"inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-[11px] font-medium transition-colors",
|
|
383
|
+
chip.active
|
|
384
|
+
? "border-dashed border-brand-purple/30 bg-brand-purple/5 text-brand-purple/80"
|
|
385
|
+
: "border-border/40 bg-transparent text-muted-foreground/60 line-through"
|
|
386
|
+
)}
|
|
387
|
+
>
|
|
388
|
+
<span className="text-brand-purple/50 text-[10px]">
|
|
389
|
+
{presetLabel}:{" "}
|
|
390
|
+
</span>
|
|
391
|
+
{chip.label}
|
|
392
|
+
</button>
|
|
393
|
+
))}
|
|
394
|
+
</div>
|
|
395
|
+
)
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
return triggerButton
|
|
211
399
|
}
|
|
@@ -242,7 +242,7 @@ export interface EntityMetadataField {
|
|
|
242
242
|
|
|
243
243
|
export function EntityMetadataGrid({ fields }: { fields: EntityMetadataField[] }) {
|
|
244
244
|
return (
|
|
245
|
-
<div className="grid grid-cols-1 md:grid-cols-[140px_1fr] gap-y-3 gap-x-4 mb-7 text-[13px]">
|
|
245
|
+
<div className="grid grid-cols-1 md:grid-cols-[140px_1fr] gap-y-3 gap-x-4 mb-7 text-[13px] overflow-hidden">
|
|
246
246
|
{fields.map((field, idx) => (
|
|
247
247
|
<React.Fragment key={idx}>
|
|
248
248
|
<div className="flex items-center gap-1.5 text-muted-foreground text-[13px] font-normal">
|
package/src/index.ts
CHANGED
|
@@ -19,11 +19,13 @@ export * from "./components/avatar"
|
|
|
19
19
|
export * from "./components/badge"
|
|
20
20
|
export * from "./components/button"
|
|
21
21
|
export * from "./components/card"
|
|
22
|
+
export { CollapsibleSection, type CollapsibleSectionProps } from "./components/collapsible-section"
|
|
22
23
|
export * from "./components/compliance-badge"
|
|
23
24
|
export * from "./components/contact-chip"
|
|
24
25
|
export * from "./components/contact-list"
|
|
25
26
|
export * from "./components/dashboard-cards"
|
|
26
27
|
export * from "./components/data-table"
|
|
28
|
+
export * from "./components/data-table-condition-filter"
|
|
27
29
|
export * from "./components/data-table-display"
|
|
28
30
|
export * from "./components/data-table-filter"
|
|
29
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 ?? ''}
|