@handled-ai/design-system 0.16.2 → 0.17.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.
@@ -0,0 +1,326 @@
1
+ import "@testing-library/jest-dom/vitest";
2
+ import { describe, it, expect, vi } from "vitest";
3
+ import React from "react";
4
+ import { fireEvent, render, screen, within } from "@testing-library/react";
5
+ import { DetailView, type DetailViewProps } from "../prototype-inbox-view";
6
+ import type { QueueItem, SignalScoreData } from "../prototype-config";
7
+
8
+ const baseItem: QueueItem = {
9
+ id: "case-1",
10
+ title: "Test Signal",
11
+ details: "Some details",
12
+ statusColor: "green",
13
+ time: "2h ago",
14
+ company: "Acme Inc",
15
+ tag1: "renewal",
16
+ };
17
+
18
+ function makeSignalScore(overrides: Partial<SignalScoreData> = {}): SignalScoreData {
19
+ return {
20
+ score: 82,
21
+ factors: [
22
+ { key: "trigger", label: "Trigger strength", score: 70, why: "Strong signal" },
23
+ { key: "fit", label: "Company fit", score: 55, why: "Good fit" },
24
+ ],
25
+ whyNow: "Strong signals detected.",
26
+ evidence: ["Evidence line 1"],
27
+ confidence: 80,
28
+ ...overrides,
29
+ };
30
+ }
31
+
32
+ function baseProps(overrides: Partial<DetailViewProps> = {}): DetailViewProps {
33
+ return {
34
+ item: baseItem,
35
+ sections: { signalBrief: true, suggestedActions: false, timeline: false },
36
+ getSignalScore: () => makeSignalScore(),
37
+ buildSuggestedActions: () => [],
38
+ buildSourceItems: () => [],
39
+ accountContacts: [],
40
+ emailSignature: "",
41
+ iconMap: {},
42
+ ...overrides,
43
+ };
44
+ }
45
+
46
+ describe("DetailView corrected compact score WHY UX", () => {
47
+ it("renders priority in metadata row without the old signal score card", () => {
48
+ render(<DetailView {...baseProps()} />);
49
+
50
+ expect(screen.getByRole("button", { name: /urgent priority/i })).toBeInTheDocument();
51
+ expect(screen.queryByText("Signal score")).toBeNull();
52
+ expect(screen.queryByText("82")).toBeNull();
53
+ expect(screen.queryByText("/100")).toBeNull();
54
+ expect(screen.queryByText("Select a chip for details")).toBeNull();
55
+ expect(screen.queryByText("How's this score?")).toBeNull();
56
+ });
57
+
58
+ it("uses provided priority label before fallback thresholds", () => {
59
+ render(
60
+ <DetailView
61
+ {...baseProps({
62
+ getSignalScore: () => makeSignalScore({ score: 92, urgencyLabel: "Medium" }),
63
+ })}
64
+ />,
65
+ );
66
+
67
+ expect(screen.getByRole("button", { name: /medium priority/i })).toBeInTheDocument();
68
+ expect(screen.queryByRole("button", { name: /urgent priority/i })).toBeNull();
69
+ });
70
+
71
+ it("opens priority explanation from metadata independently of score feedback", () => {
72
+ render(
73
+ <DetailView
74
+ {...baseProps({
75
+ getSignalScore: () =>
76
+ makeSignalScore({ urgencyExplanation: "Customer activity spiked today." }),
77
+ })}
78
+ />,
79
+ );
80
+
81
+ fireEvent.click(screen.getByRole("button", { name: /urgent priority/i }));
82
+
83
+ expect(screen.getByText("Customer activity spiked today.")).toBeInTheDocument();
84
+ expect(screen.getByText("Score 82/100")).toBeInTheDocument();
85
+ expect(screen.getByText("Urgent range: 80-100")).toBeInTheDocument();
86
+ expect(screen.getByText("Why now")).toBeInTheDocument();
87
+ const priorityPanel = screen.getByRole("region", { name: /priority explanation/i });
88
+ expect(within(priorityPanel).getByText("Strong signals detected.")).toBeInTheDocument();
89
+ expect(screen.getByText("Top factor")).toBeInTheDocument();
90
+ expect(screen.getAllByText("Strong signal").length).toBeGreaterThan(0);
91
+ expect(screen.getByText("Trigger strength")).toBeInTheDocument();
92
+ expect(screen.getByText("Company fit")).toBeInTheDocument();
93
+ expect(screen.queryByText("How's this score?")).toBeNull();
94
+ });
95
+
96
+ it("keeps signal WHY chips collapsed by default and excludes factor-only buckets", () => {
97
+ render(
98
+ <DetailView
99
+ {...baseProps({
100
+ getSignalScore: () =>
101
+ makeSignalScore({
102
+ explanationBuckets: [
103
+ { key: "signal-a", label: "Treasury activity", kind: "signal", primarySignalId: "sig-1", signals: [{ id: "sig-1", label: "Treasury signal" }] },
104
+ { key: "factor-b", label: "Relationship depth", kind: "factor", rationale: "Relationship rationale", factorKeys: ["fit"] },
105
+ ],
106
+ }),
107
+ })}
108
+ />,
109
+ );
110
+
111
+ expect(screen.getByText("Treasury activity")).toBeInTheDocument();
112
+ expect(screen.queryByText("Relationship depth")).toBeNull();
113
+ expect(screen.queryByRole("region", { name: /treasury activity details/i })).toBeNull();
114
+
115
+ fireEvent.click(screen.getByRole("button", { name: /treasury activity/i }));
116
+ expect(screen.getByRole("region", { name: /treasury activity details/i })).toBeInTheDocument();
117
+ });
118
+
119
+ it("resets selected bucket and priority panel when item id changes", () => {
120
+ const { rerender } = render(<DetailView {...baseProps()} />);
121
+
122
+ fireEvent.click(screen.getByRole("button", { name: /urgent priority/i }));
123
+ expect(screen.getByRole("region", { name: /priority explanation/i })).toBeInTheDocument();
124
+
125
+ rerender(
126
+ <DetailView
127
+ {...baseProps({
128
+ item: { ...baseItem, id: "case-2", title: "Second Signal" },
129
+ })}
130
+ />,
131
+ );
132
+
133
+ expect(screen.queryByRole("region", { name: /priority explanation/i })).toBeNull();
134
+ });
135
+
136
+ it("does not render factor-derived WHY chips when no signal buckets are present", () => {
137
+ render(<DetailView {...baseProps()} />);
138
+
139
+ expect(screen.queryByRole("button", { name: /trigger strength/i })).toBeNull();
140
+ expect(screen.queryByRole("button", { name: /company fit/i })).toBeNull();
141
+ });
142
+
143
+ it("honors an explicit empty explanationBuckets array", () => {
144
+ render(
145
+ <DetailView
146
+ {...baseProps({
147
+ getSignalScore: () => makeSignalScore({ explanationBuckets: [] }),
148
+ })}
149
+ />,
150
+ );
151
+
152
+ expect(screen.queryByRole("button", { name: /trigger strength/i })).toBeNull();
153
+ expect(screen.queryByRole("button", { name: /company fit/i })).toBeNull();
154
+ });
155
+
156
+ it("does not render a WHY row when no buckets or legacy factors exist", () => {
157
+ render(
158
+ <DetailView
159
+ {...baseProps({
160
+ getSignalScore: () => makeSignalScore({ factors: [], explanationBuckets: [] }),
161
+ })}
162
+ />,
163
+ );
164
+
165
+ expect(screen.getByRole("button", { name: /urgent priority/i })).toBeInTheDocument();
166
+ expect(screen.queryByRole("button", { name: /trigger strength/i })).toBeNull();
167
+ expect(screen.queryByRole("button", { name: /company fit/i })).toBeNull();
168
+ });
169
+
170
+ it("exposes aria-expanded and aria-controls for priority and bucket triggers", () => {
171
+ render(
172
+ <DetailView
173
+ {...baseProps({
174
+ getSignalScore: () =>
175
+ makeSignalScore({
176
+ explanationBuckets: [
177
+ { key: "signal:a/test", label: "Signal A", kind: "signal", signals: [{ id: "sig-a", label: "Signal A event" }] },
178
+ ],
179
+ }),
180
+ })}
181
+ />,
182
+ );
183
+
184
+ const priorityButton = screen.getByRole("button", { name: /urgent priority/i });
185
+ expect(priorityButton.getAttribute("aria-expanded")).toBe("false");
186
+ expect(priorityButton.getAttribute("aria-controls")).toBeTruthy();
187
+ fireEvent.click(priorityButton);
188
+ expect(priorityButton.getAttribute("aria-expanded")).toBe("true");
189
+ expect(document.getElementById(priorityButton.getAttribute("aria-controls")!)).toBeInTheDocument();
190
+
191
+ const bucketButton = screen.getByRole("button", { name: /signal a/i });
192
+ expect(bucketButton.getAttribute("aria-expanded")).toBe("false");
193
+ expect(bucketButton.getAttribute("aria-controls")).toBeTruthy();
194
+ fireEvent.click(bucketButton);
195
+ expect(bucketButton.getAttribute("aria-expanded")).toBe("true");
196
+ expect(document.getElementById(bucketButton.getAttribute("aria-controls")!)).toBeInTheDocument();
197
+ });
198
+
199
+ it("renders repeated signal groups as one chip with a count and signal-id rows", () => {
200
+ const onOpenSignalBucket = vi.fn();
201
+ render(
202
+ <DetailView
203
+ {...baseProps({
204
+ getSignalScore: () =>
205
+ makeSignalScore({
206
+ explanationBuckets: [
207
+ { key: "signal-repeat", label: "Treasury activity", kind: "signal", signalCount: 3, signalIds: ["sig-1", "sig-2", "sig-3"] },
208
+ ],
209
+ }),
210
+ onOpenSignalBucket,
211
+ })}
212
+ />,
213
+ );
214
+
215
+ expect(screen.getAllByRole("button", { name: /treasury activity/i })).toHaveLength(1);
216
+ expect(screen.getByText("×3")).toBeInTheDocument();
217
+
218
+ fireEvent.click(screen.getByRole("button", { name: /treasury activity/i }));
219
+ const matchingSignals = screen.getByRole("list", { name: /matching signals/i });
220
+ expect(within(matchingSignals).getAllByRole("button", { name: /treasury activity signal/i })).toHaveLength(3);
221
+ fireEvent.click(within(matchingSignals).getAllByRole("button", { name: /treasury activity signal/i })[1]);
222
+ expect(onOpenSignalBucket).toHaveBeenCalledWith({
223
+ item: baseItem,
224
+ bucketKey: "signal-repeat",
225
+ signalId: "sig-2",
226
+ });
227
+ });
228
+
229
+ it("renders designed matching signal rows from bucket signals", () => {
230
+ render(
231
+ <DetailView
232
+ {...baseProps({
233
+ getSignalScore: () =>
234
+ makeSignalScore({
235
+ explanationBuckets: [
236
+ {
237
+ key: "signals",
238
+ label: "Treasury activity",
239
+ kind: "signal",
240
+ signals: [
241
+ {
242
+ id: "sig-1",
243
+ label: "ACH volume increased",
244
+ description: "Payment activity is 40% above baseline.",
245
+ source: "Banking data",
246
+ time: "Today",
247
+ metric: "+40%",
248
+ },
249
+ ],
250
+ },
251
+ ],
252
+ }),
253
+ })}
254
+ />,
255
+ );
256
+
257
+ fireEvent.click(screen.getByRole("button", { name: /treasury activity/i }));
258
+ const matchingSignals = screen.getByRole("list", { name: /matching signals/i });
259
+ expect(within(matchingSignals).getByText("ACH volume increased")).toBeInTheDocument();
260
+ expect(within(matchingSignals).getByText("Payment activity is 40% above baseline.")).toBeInTheDocument();
261
+ expect(within(matchingSignals).getByText("Banking data")).toBeInTheDocument();
262
+ expect(within(matchingSignals).getByText("+40%")).toBeInTheDocument();
263
+ });
264
+
265
+ it("opens a matching signal row with the selected signal id", () => {
266
+ const onOpenSignalBucket = vi.fn();
267
+ render(
268
+ <DetailView
269
+ {...baseProps({
270
+ getSignalScore: () =>
271
+ makeSignalScore({
272
+ explanationBuckets: [
273
+ {
274
+ key: "with-signals",
275
+ label: "With signals",
276
+ kind: "signal",
277
+ signalCount: 2,
278
+ signals: [
279
+ { id: "sig-1", label: "Latest signal", description: "Newest signal" },
280
+ { id: "sig-2", label: "Older signal", description: "Older signal" },
281
+ ],
282
+ },
283
+ ],
284
+ }),
285
+ onOpenSignalBucket,
286
+ })}
287
+ />,
288
+ );
289
+
290
+ fireEvent.click(screen.getByRole("button", { name: /with signals/i }));
291
+ fireEvent.click(screen.getByRole("button", { name: /older signal/i }));
292
+
293
+ expect(onOpenSignalBucket).toHaveBeenCalledWith({
294
+ item: baseItem,
295
+ bucketKey: "with-signals",
296
+ signalId: "sig-2",
297
+ });
298
+ });
299
+
300
+ it("shows factor feedback in the priority panel, not signal WHY panels", () => {
301
+ const onFactorFeedback = vi.fn();
302
+ render(
303
+ <DetailView
304
+ {...baseProps({
305
+ getSignalScore: () =>
306
+ makeSignalScore({
307
+ onFactorFeedback,
308
+ initialFactorFeedback: { fit: { type: "down", detail: "Needs review" } },
309
+ explanationBuckets: [
310
+ { key: "signal-a", label: "Signal only", kind: "signal", primarySignalId: "sig-1", signals: [{ id: "sig-1", label: "Signal event" }] },
311
+ ],
312
+ }),
313
+ })}
314
+ />,
315
+ );
316
+
317
+ fireEvent.click(screen.getByRole("button", { name: /signal only/i }));
318
+ const signalPanel = screen.getByRole("region", { name: /signal only details/i });
319
+ expect(within(signalPanel).queryByTitle("This factor is accurate")).toBeNull();
320
+
321
+ fireEvent.click(screen.getByRole("button", { name: /urgent priority/i }));
322
+ const priorityPanel = screen.getByRole("region", { name: /priority explanation/i });
323
+ expect(within(priorityPanel).getAllByTitle("This factor is accurate").length).toBeGreaterThan(0);
324
+ expect(within(priorityPanel).getByText("Needs review")).toBeInTheDocument();
325
+ });
326
+ });
@@ -0,0 +1,72 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import React from "react";
3
+ import { render, screen } from "@testing-library/react";
4
+ import { DetailView, type DetailViewProps } from "../prototype-inbox-view";
5
+ import type { QueueItem, SignalScoreData } from "../prototype-config";
6
+
7
+ const baseItem: QueueItem = {
8
+ id: "case-1",
9
+ title: "QA Placement Churn Risk Signal",
10
+ details: "Some details",
11
+ statusColor: "red",
12
+ time: "2h ago",
13
+ company: "QA Placement Account",
14
+ tag1: "Urgent Priority",
15
+ };
16
+
17
+ function signalScore(): SignalScoreData {
18
+ return {
19
+ score: 82,
20
+ factors: [],
21
+ whyNow: "Strong signal detected.",
22
+ evidence: [],
23
+ confidence: 75,
24
+ };
25
+ }
26
+
27
+ function baseProps(overrides: Partial<DetailViewProps> = {}): DetailViewProps {
28
+ return {
29
+ item: baseItem,
30
+ sections: { signalBrief: true, suggestedActions: false, timeline: false },
31
+ getSignalScore: () => signalScore(),
32
+ buildSuggestedActions: () => [],
33
+ buildSourceItems: () => [],
34
+ accountContacts: [],
35
+ emailSignature: "",
36
+ iconMap: {},
37
+ ...overrides,
38
+ };
39
+ }
40
+
41
+ describe("DetailView title slots", () => {
42
+ it("renders supporting title text below the main title", () => {
43
+ render(
44
+ <DetailView
45
+ {...baseProps({
46
+ renderTitleSubtext: (item) => (
47
+ <p data-testid="title-subtext">Full case: {item.title}</p>
48
+ ),
49
+ })}
50
+ />,
51
+ );
52
+
53
+ expect(screen.getByRole("heading", { name: "QA Placement Churn Risk Signal" })).toBeTruthy();
54
+ expect(screen.getByTestId("title-subtext").textContent).toBe(
55
+ "Full case: QA Placement Churn Risk Signal",
56
+ );
57
+ });
58
+
59
+ it("renders title extra content beside the main title", () => {
60
+ render(
61
+ <DetailView
62
+ {...baseProps({
63
+ renderTitleExtra: () => (
64
+ <button type="button" data-testid="title-extra">Quick action</button>
65
+ ),
66
+ })}
67
+ />,
68
+ );
69
+
70
+ expect(screen.getByTestId("title-extra").textContent).toBe("Quick action");
71
+ });
72
+ });
@@ -36,16 +36,49 @@ export interface QueueItem {
36
36
  tag1: string
37
37
  }
38
38
 
39
+ export type SignalScoreUrgencyLabel = "Low" | "Medium" | "High" | "Urgent"
40
+
41
+ export interface SignalScoreExplanationSignal {
42
+ id?: string
43
+ label: string
44
+ description?: string
45
+ source?: string
46
+ time?: string
47
+ metric?: string
48
+ }
49
+
50
+ export interface SignalScoreExplanationBucket {
51
+ key: string
52
+ label: string
53
+ kind: "signal" | "factor" | "merged"
54
+ score?: number
55
+ classification?: string
56
+ rationale?: string
57
+ evidence?: string[]
58
+ signals?: SignalScoreExplanationSignal[]
59
+ primaryMetricLabel?: string
60
+ primaryMetricValue?: string
61
+ signalCount?: number
62
+ signalIds?: string[]
63
+ primarySignalId?: string
64
+ factorKeys?: string[]
65
+ }
66
+
39
67
  export interface SignalScoreData {
40
68
  score: number
41
69
  factors: ScoreFactor[]
42
70
  whyNow: string
43
71
  evidence: string[]
44
72
  confidence: number
73
+ urgencyLabel?: SignalScoreUrgencyLabel
74
+ urgencyExplanation?: string
75
+ explanationBuckets?: SignalScoreExplanationBucket[]
45
76
  onFactorFeedback?: (factorKey: string, type: "up" | "down" | null, detail?: string) => void
77
+ /** @deprecated The compact score UX no longer renders score-level thumbs by default. */
46
78
  onScoreFeedback?: (type: "up" | "down", pills: string[], detail: string) => void
47
79
  onApproveFeedback?: (reasons: string[], detail: string) => void
48
80
  onDismissFeedback?: (reasons: string[], detail: string, subReason?: string) => void
81
+ /** @deprecated The compact score UX no longer renders score-level thumbs by default. */
49
82
  initialScoreFeedback?: { type: "up" | "down"; pills: string[]; detail: string } | null
50
83
  initialFactorFeedback?: Record<string, { type: "up" | "down"; detail: string }>
51
84
  /** AI-generated signal brief text. When present, rendered in a dedicated section. */
@@ -89,7 +122,9 @@ export interface InboxViewConfig {
89
122
  hideToolbarActions?: boolean
90
123
  hideHoverActions?: boolean
91
124
  onSuggestedActionFeedback?: (actionId: number | string, feedback: string, actionTitle?: string) => void
125
+ /** @deprecated The compact score UX no longer renders score-level thumbs by default. */
92
126
  onScoreFeedback?: (type: "up" | "down", pills: string[], detail: string) => void
127
+ onOpenSignalBucket?: (args: { item: QueueItem; bucketKey: string; signalId: string }) => void
93
128
  buildEntityChips?: (item: QueueItem) => Array<{ id: string; label: string; avatarLetter: string; onClick?: () => void }>
94
129
  quickFilterTabs?: Array<{ id: string; label: string; matchValue?: string; count?: number }>
95
130
  hideAccountsButton?: boolean
@@ -131,6 +166,10 @@ export interface InboxViewConfig {
131
166
  briefStyleVariant?: BriefStyleVariant
132
167
  /** Render extra content at the end of the detail view, below the suggested actions section. */
133
168
  renderDetailExtra?: (item: QueueItem) => React.ReactNode
169
+ /** Render extra content inline with the detail title. */
170
+ renderTitleExtra?: (item: QueueItem) => React.ReactNode
171
+ /** Render supporting content below the detail title. */
172
+ renderTitleSubtext?: (item: QueueItem) => React.ReactNode
134
173
  /** Render content between the signal brief text and the signal score bar (e.g. "Signals on Case" chips). */
135
174
  renderBeforeScore?: (item: QueueItem) => React.ReactNode
136
175
  /** Render content between the signal score section and the activity timeline (e.g. OpportunityPanel). */