@handled-ai/design-system 0.18.4 → 0.18.5
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/charts/chart.d.ts +1 -1
- package/dist/charts/empty-chart-state.d.ts +11 -0
- package/dist/charts/empty-chart-state.js +70 -0
- package/dist/charts/empty-chart-state.js.map +1 -0
- package/dist/charts/index.d.ts +1 -0
- package/dist/charts/index.js +1 -0
- package/dist/charts/index.js.map +1 -1
- package/dist/charts/pipeline-overview.d.ts +2 -1
- package/dist/charts/pipeline-overview.js +32 -1
- package/dist/charts/pipeline-overview.js.map +1 -1
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/button.d.ts +1 -1
- package/dist/components/days-open-cell.d.ts +16 -0
- package/dist/components/days-open-cell.js +73 -0
- package/dist/components/days-open-cell.js.map +1 -0
- package/dist/components/detail-drawer.d.ts +16 -0
- package/dist/components/detail-drawer.js +45 -0
- package/dist/components/detail-drawer.js.map +1 -0
- package/dist/components/feedback-primitives.d.ts +2 -41
- package/dist/components/feedback-primitives.js +6 -241
- package/dist/components/feedback-primitives.js.map +1 -1
- package/dist/components/insights-filter-bar.d.ts +2 -1
- package/dist/components/insights-filter-bar.js +13 -5
- package/dist/components/insights-filter-bar.js.map +1 -1
- package/dist/components/linked-entity-cell.d.ts +14 -0
- package/dist/components/linked-entity-cell.js +96 -0
- package/dist/components/linked-entity-cell.js.map +1 -0
- package/dist/components/metric-card.d.ts +14 -1
- package/dist/components/metric-card.js +97 -0
- package/dist/components/metric-card.js.map +1 -1
- package/dist/components/pill.d.ts +26 -0
- package/dist/components/pill.js +77 -0
- package/dist/components/pill.js.map +1 -0
- package/dist/components/quick-segment.d.ts +13 -0
- package/dist/components/quick-segment.js +96 -0
- package/dist/components/quick-segment.js.map +1 -0
- package/dist/components/score-why-chips.d.ts +1 -1
- package/dist/components/score-why-chips.js +5 -26
- package/dist/components/score-why-chips.js.map +1 -1
- package/dist/components/signal-priority-popover.d.ts +1 -1
- package/dist/components/signal-priority-popover.js +6 -32
- package/dist/components/signal-priority-popover.js.map +1 -1
- package/dist/components/tabs.d.ts +1 -1
- package/dist/index.d.ts +9 -3
- package/dist/index.js +6 -2
- package/dist/index.js.map +1 -1
- package/dist/prototype/index.d.ts +1 -1
- package/dist/prototype/prototype-accounts-view.d.ts +1 -1
- package/dist/prototype/prototype-admin-view.d.ts +1 -1
- package/dist/prototype/prototype-config.d.ts +1 -1
- package/dist/prototype/prototype-inbox-view.d.ts +1 -1
- package/dist/prototype/prototype-inbox-view.js +1 -4
- package/dist/prototype/prototype-inbox-view.js.map +1 -1
- package/dist/prototype/prototype-insights-view.d.ts +1 -1
- package/dist/prototype/prototype-shell.d.ts +1 -1
- package/dist/{signal-priority-popover-DWaAMhPI.d.ts → signal-priority-popover-DQ_VuHac.d.ts} +2 -26
- package/package.json +1 -3
- package/src/charts/__tests__/insights-charts.test.tsx +62 -0
- package/src/charts/empty-chart-state.tsx +44 -0
- package/src/charts/index.ts +1 -0
- package/src/charts/pipeline-overview.tsx +41 -1
- package/src/components/__tests__/insights-primitives.test.tsx +135 -0
- package/src/components/days-open-cell.tsx +50 -0
- package/src/components/detail-drawer.tsx +60 -0
- package/src/components/feedback-primitives.tsx +26 -333
- package/src/components/insights-filter-bar.tsx +13 -4
- package/src/components/linked-entity-cell.tsx +74 -0
- package/src/components/metric-card.tsx +98 -0
- package/src/components/pill.tsx +67 -0
- package/src/components/quick-segment.tsx +68 -0
- package/src/components/score-why-chips.tsx +2 -28
- package/src/components/signal-priority-popover.tsx +4 -44
- package/src/index.ts +7 -2
- package/src/prototype/prototype-config.ts +1 -11
- package/src/prototype/prototype-inbox-view.tsx +0 -3
- package/src/components/__tests__/wit-636-feedback-states.test.tsx +0 -546
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import React from "react"
|
|
2
|
+
import { describe, expect, it, vi } from "vitest"
|
|
3
|
+
import { render, screen, fireEvent } from "@testing-library/react"
|
|
4
|
+
|
|
5
|
+
import { DaysOpenCell, getDaysOpenIntent } from "../days-open-cell"
|
|
6
|
+
import { DetailDrawer } from "../detail-drawer"
|
|
7
|
+
import { InsightsFilterBar } from "../insights-filter-bar"
|
|
8
|
+
import { LinkedEntityCell } from "../linked-entity-cell"
|
|
9
|
+
import { KpiStrip } from "../metric-card"
|
|
10
|
+
import { Pill, StatusPill } from "../pill"
|
|
11
|
+
import { QuickSegment } from "../quick-segment"
|
|
12
|
+
|
|
13
|
+
describe("Insights primitives", () => {
|
|
14
|
+
it("renders compact InsightsFilterBar without changing default API", () => {
|
|
15
|
+
const { container } = render(
|
|
16
|
+
<InsightsFilterBar
|
|
17
|
+
variant="compact"
|
|
18
|
+
filters={[{ id: "status", label: "Status", options: ["All", "Open"], defaultValue: "All" }]}
|
|
19
|
+
values={{ status: "Open" }}
|
|
20
|
+
onChange={() => {}}
|
|
21
|
+
onClearAll={() => {}}
|
|
22
|
+
/>
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
const bar = container.querySelector('[data-slot="insights-filter-bar"]')!
|
|
26
|
+
expect(bar.className).toContain("p-2")
|
|
27
|
+
expect(bar.className).toContain("gap-2")
|
|
28
|
+
expect(screen.getByRole("button", { name: /Status: Open/i }).className).toContain("h-7")
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it("renders KpiStrip items and changes", () => {
|
|
32
|
+
const { container } = render(
|
|
33
|
+
<KpiStrip
|
|
34
|
+
items={[
|
|
35
|
+
{ label: "New", value: 42, unit: "leads", change: { value: "8%", direction: "up" } },
|
|
36
|
+
{ label: "Aging", value: 12, subtitle: "over SLA", change: { value: "3%", direction: "down", isGood: true } },
|
|
37
|
+
]}
|
|
38
|
+
/>
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
expect(container.querySelectorAll('[data-slot="kpi-strip-item"]')).toHaveLength(2)
|
|
42
|
+
expect(screen.getByText("New")).not.toBeNull()
|
|
43
|
+
expect(screen.getByText("42")).not.toBeNull()
|
|
44
|
+
expect(screen.getByText("8%")).not.toBeNull()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it("renders neutral KpiStrip changes without an up/down icon or red/green intent", () => {
|
|
48
|
+
const { container } = render(
|
|
49
|
+
<KpiStrip
|
|
50
|
+
items={[
|
|
51
|
+
{ label: "Stable", value: 10, change: { value: "0%", direction: "neutral" } },
|
|
52
|
+
]}
|
|
53
|
+
/>
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
const change = container.querySelector('[data-slot="kpi-strip-change"]')
|
|
57
|
+
expect(change?.className).toContain("text-muted-foreground")
|
|
58
|
+
expect(change?.className).not.toContain("text-red-600")
|
|
59
|
+
expect(change?.className).not.toContain("text-emerald-600")
|
|
60
|
+
expect(change?.querySelector("svg")).toBeNull()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it("QuickSegment exposes selected state and invokes onSelect", () => {
|
|
64
|
+
const onSelect = vi.fn()
|
|
65
|
+
render(<QuickSegment label="At risk" value="risk" count={5} selected onSelect={onSelect} />)
|
|
66
|
+
|
|
67
|
+
const button = screen.getByRole("button", { name: /At risk/i })
|
|
68
|
+
expect(button.getAttribute("aria-pressed")).toBe("true")
|
|
69
|
+
expect(screen.getByText("5")).not.toBeNull()
|
|
70
|
+
fireEvent.click(button)
|
|
71
|
+
expect(onSelect).toHaveBeenCalledWith("risk")
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it("LinkedEntityCell renders entity links and metadata", () => {
|
|
75
|
+
const onNavigate = vi.fn()
|
|
76
|
+
render(
|
|
77
|
+
<LinkedEntityCell
|
|
78
|
+
name="Acme Health"
|
|
79
|
+
href="/accounts/acme"
|
|
80
|
+
subtitle="Account"
|
|
81
|
+
meta="Tier 1"
|
|
82
|
+
onNavigate={onNavigate}
|
|
83
|
+
/>
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
const link = screen.getByRole("link", { name: "Acme Health" })
|
|
87
|
+
expect(link.getAttribute("href")).toBe("/accounts/acme")
|
|
88
|
+
expect(screen.getByText(/Tier 1/)).not.toBeNull()
|
|
89
|
+
fireEvent.click(link)
|
|
90
|
+
expect(onNavigate).toHaveBeenCalled()
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it("DaysOpenCell maps thresholds to status intents", () => {
|
|
94
|
+
expect(getDaysOpenIntent(2, 7, 30)).toBe("success")
|
|
95
|
+
expect(getDaysOpenIntent(10, 7, 30)).toBe("warning")
|
|
96
|
+
expect(getDaysOpenIntent(45, 7, 30)).toBe("error")
|
|
97
|
+
|
|
98
|
+
render(<DaysOpenCell days={45} />)
|
|
99
|
+
expect(screen.getByTestId("days-open-pill").getAttribute("data-variant")).toBe("error")
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it("Pill and StatusPill render wrapper variants", () => {
|
|
103
|
+
const { container } = render(
|
|
104
|
+
<div>
|
|
105
|
+
<Pill variant="info">Info</Pill>
|
|
106
|
+
<StatusPill status="Blocked" intent="error" />
|
|
107
|
+
</div>
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
expect(container.querySelector('[data-slot="pill"]')?.getAttribute("data-variant")).toBe("info")
|
|
111
|
+
expect(container.querySelector('[data-slot="status-pill"]')?.getAttribute("data-variant")).toBe("error")
|
|
112
|
+
expect(screen.getByText("Blocked")).not.toBeNull()
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it("DetailDrawer renders title, content, and footer when open", () => {
|
|
116
|
+
render(
|
|
117
|
+
<DetailDrawer
|
|
118
|
+
open
|
|
119
|
+
onOpenChange={() => {}}
|
|
120
|
+
title="Referral details"
|
|
121
|
+
description="A drawer for insights"
|
|
122
|
+
footer={<button type="button">Done</button>}
|
|
123
|
+
>
|
|
124
|
+
<div>Drawer body</div>
|
|
125
|
+
</DetailDrawer>
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
expect(screen.getByText("Referral details")).not.toBeNull()
|
|
129
|
+
expect(screen.getByText("A drawer for insights")).not.toBeNull()
|
|
130
|
+
expect(screen.getByText("Drawer body")).not.toBeNull()
|
|
131
|
+
expect(screen.getByRole("button", { name: "Done" })).not.toBeNull()
|
|
132
|
+
expect(document.querySelector('[data-slot="detail-drawer"]')?.className).toContain("flex")
|
|
133
|
+
expect(document.querySelector('[data-slot="detail-drawer"]')?.className).toContain("flex-col")
|
|
134
|
+
})
|
|
135
|
+
})
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
|
|
5
|
+
import { cn } from "../lib/utils"
|
|
6
|
+
import { StatusPill, type PillStatus } from "./pill"
|
|
7
|
+
|
|
8
|
+
export interface DaysOpenCellProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
9
|
+
days: number | null | undefined
|
|
10
|
+
warningAt?: number
|
|
11
|
+
criticalAt?: number
|
|
12
|
+
emptyLabel?: string
|
|
13
|
+
suffix?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function getDaysOpenIntent(days: number, warningAt: number, criticalAt: number): PillStatus {
|
|
17
|
+
if (days >= criticalAt) return "error"
|
|
18
|
+
if (days >= warningAt) return "warning"
|
|
19
|
+
return "success"
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function DaysOpenCell({
|
|
23
|
+
days,
|
|
24
|
+
warningAt = 7,
|
|
25
|
+
criticalAt = 30,
|
|
26
|
+
emptyLabel = "—",
|
|
27
|
+
suffix = "d open",
|
|
28
|
+
className,
|
|
29
|
+
...props
|
|
30
|
+
}: DaysOpenCellProps) {
|
|
31
|
+
if (days === null || days === undefined) {
|
|
32
|
+
return (
|
|
33
|
+
<div data-slot="days-open-cell" className={cn("text-sm text-muted-foreground", className)} {...props}>
|
|
34
|
+
{emptyLabel}
|
|
35
|
+
</div>
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const intent = getDaysOpenIntent(days, warningAt, criticalAt)
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div data-slot="days-open-cell" className={cn("inline-flex items-center", className)} {...props}>
|
|
43
|
+
<StatusPill data-testid="days-open-pill" status={`${days} ${suffix}`} intent={intent}>
|
|
44
|
+
{days} {suffix}
|
|
45
|
+
</StatusPill>
|
|
46
|
+
</div>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export { getDaysOpenIntent }
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
|
|
5
|
+
import { cn } from "../lib/utils"
|
|
6
|
+
import {
|
|
7
|
+
Sheet,
|
|
8
|
+
SheetContent,
|
|
9
|
+
SheetDescription,
|
|
10
|
+
SheetFooter,
|
|
11
|
+
SheetHeader,
|
|
12
|
+
SheetTitle,
|
|
13
|
+
} from "./sheet"
|
|
14
|
+
|
|
15
|
+
export interface DetailDrawerProps {
|
|
16
|
+
open: boolean
|
|
17
|
+
onOpenChange: (open: boolean) => void
|
|
18
|
+
title: React.ReactNode
|
|
19
|
+
description?: React.ReactNode
|
|
20
|
+
children: React.ReactNode
|
|
21
|
+
footer?: React.ReactNode
|
|
22
|
+
side?: "right" | "left"
|
|
23
|
+
className?: string
|
|
24
|
+
contentClassName?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function DetailDrawer({
|
|
28
|
+
open,
|
|
29
|
+
onOpenChange,
|
|
30
|
+
title,
|
|
31
|
+
description,
|
|
32
|
+
children,
|
|
33
|
+
footer,
|
|
34
|
+
side = "right",
|
|
35
|
+
className,
|
|
36
|
+
contentClassName,
|
|
37
|
+
}: DetailDrawerProps) {
|
|
38
|
+
return (
|
|
39
|
+
<Sheet open={open} onOpenChange={onOpenChange}>
|
|
40
|
+
<SheetContent
|
|
41
|
+
data-slot="detail-drawer"
|
|
42
|
+
side={side}
|
|
43
|
+
className={cn("flex w-full flex-col gap-0 p-0 sm:max-w-xl", className)}
|
|
44
|
+
>
|
|
45
|
+
<SheetHeader data-slot="detail-drawer-header" className="border-b border-border p-5">
|
|
46
|
+
<SheetTitle>{title}</SheetTitle>
|
|
47
|
+
{description ? <SheetDescription>{description}</SheetDescription> : null}
|
|
48
|
+
</SheetHeader>
|
|
49
|
+
<div data-slot="detail-drawer-content" className={cn("flex-1 overflow-y-auto p-5", contentClassName)}>
|
|
50
|
+
{children}
|
|
51
|
+
</div>
|
|
52
|
+
{footer ? (
|
|
53
|
+
<SheetFooter data-slot="detail-drawer-footer" className="border-t border-border p-5">
|
|
54
|
+
{footer}
|
|
55
|
+
</SheetFooter>
|
|
56
|
+
) : null}
|
|
57
|
+
</SheetContent>
|
|
58
|
+
</Sheet>
|
|
59
|
+
)
|
|
60
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
3
|
import * as React from "react"
|
|
4
|
-
import { ThumbsUp, ThumbsDown
|
|
4
|
+
import { ThumbsUp, ThumbsDown } from "lucide-react"
|
|
5
5
|
import { cn } from "../lib/utils"
|
|
6
6
|
|
|
7
7
|
// ---------------------------------------------------------------------------
|
|
@@ -25,19 +25,6 @@ export interface FeedbackSubmitData {
|
|
|
25
25
|
detail: string
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
/**
|
|
29
|
-
* Persisted feedback data from a previous submission, used to hydrate the
|
|
30
|
-
* footer into its "already submitted" visual state.
|
|
31
|
-
*/
|
|
32
|
-
export interface PersistedFeedbackData {
|
|
33
|
-
sentiment: "positive" | "negative"
|
|
34
|
-
reasonTop?: string
|
|
35
|
-
reasonSub?: string
|
|
36
|
-
pills?: string[]
|
|
37
|
-
detail?: string
|
|
38
|
-
ownershipLabel: "Your feedback" | "Team feedback"
|
|
39
|
-
}
|
|
40
|
-
|
|
41
28
|
/**
|
|
42
29
|
* Defines a tier-1 chip that may have tier-2 sub-chips.
|
|
43
30
|
*/
|
|
@@ -198,13 +185,6 @@ export interface FeedbackFooterProps {
|
|
|
198
185
|
negativeChips?: FeedbackChipTree[]
|
|
199
186
|
positiveChips?: string[]
|
|
200
187
|
className?: string
|
|
201
|
-
/** Pre-existing feedback to hydrate from (e.g. after page reload). */
|
|
202
|
-
initialFeedback?: PersistedFeedbackData | null
|
|
203
|
-
/** Label shown in the transient confirmation pill after submit. */
|
|
204
|
-
submittedLabel?: string
|
|
205
|
-
/** Stable key for syncing initialFeedback into local state. When this
|
|
206
|
-
* changes, the component resets to the new initialFeedback value. */
|
|
207
|
-
feedbackKey?: string
|
|
208
188
|
}
|
|
209
189
|
|
|
210
190
|
const SENTIMENT_BUTTON_ACTIVE: Record<"positive" | "negative", string> = {
|
|
@@ -225,9 +205,6 @@ export function FeedbackFooter({
|
|
|
225
205
|
negativeChips = [],
|
|
226
206
|
positiveChips = [],
|
|
227
207
|
className,
|
|
228
|
-
initialFeedback,
|
|
229
|
-
submittedLabel = "Saved",
|
|
230
|
-
feedbackKey,
|
|
231
208
|
}: FeedbackFooterProps) {
|
|
232
209
|
const [expanded, setExpanded] = React.useState(false)
|
|
233
210
|
const [selectedTier1, setSelectedTier1] = React.useState<string | null>(null)
|
|
@@ -237,48 +214,6 @@ export function FeedbackFooter({
|
|
|
237
214
|
const [activeTreeIndex, setActiveTreeIndex] = React.useState<number | null>(
|
|
238
215
|
null,
|
|
239
216
|
)
|
|
240
|
-
/** Transient "Saved" confirmation — shown after successful submit. */
|
|
241
|
-
const [submitted, setSubmitted] = React.useState(false)
|
|
242
|
-
/** Persisted feedback shown as a clickable indicator (survives reload). */
|
|
243
|
-
const [persisted, setPersisted] = React.useState<PersistedFeedbackData | null>(
|
|
244
|
-
initialFeedback ?? null,
|
|
245
|
-
)
|
|
246
|
-
/** Tracks whether the user is actively editing (ref to guard against prop overwrites without triggering re-syncs). */
|
|
247
|
-
const isEditingRef = React.useRef(false)
|
|
248
|
-
/** Track the last synced feedbackKey to detect key changes. */
|
|
249
|
-
const lastKeyRef = React.useRef<string | undefined>(feedbackKey)
|
|
250
|
-
|
|
251
|
-
/** Helper to update the editing ref. */
|
|
252
|
-
const setIsEditing = React.useCallback((value: boolean) => {
|
|
253
|
-
isEditingRef.current = value
|
|
254
|
-
}, [])
|
|
255
|
-
|
|
256
|
-
// Sync initialFeedback into local state via useEffect keyed on feedbackKey.
|
|
257
|
-
// When feedbackKey changes, reset to new target. Preserve active edits
|
|
258
|
-
// when feedbackKey stays the same.
|
|
259
|
-
React.useEffect(() => {
|
|
260
|
-
const keyChanged = feedbackKey !== lastKeyRef.current
|
|
261
|
-
lastKeyRef.current = feedbackKey
|
|
262
|
-
|
|
263
|
-
if (keyChanged) {
|
|
264
|
-
// Key changed — full reset to new target
|
|
265
|
-
setPersisted(initialFeedback ?? null)
|
|
266
|
-
setSubmitted(false)
|
|
267
|
-
setExpanded(false)
|
|
268
|
-
isEditingRef.current = false
|
|
269
|
-
if (initialFeedback) {
|
|
270
|
-
onFeedbackChange(initialFeedback.sentiment)
|
|
271
|
-
} else {
|
|
272
|
-
onFeedbackChange(null)
|
|
273
|
-
}
|
|
274
|
-
} else if (!isEditingRef.current) {
|
|
275
|
-
// Same key, not actively editing — safe to sync
|
|
276
|
-
setPersisted(initialFeedback ?? null)
|
|
277
|
-
if (initialFeedback) {
|
|
278
|
-
onFeedbackChange(initialFeedback.sentiment)
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
}, [initialFeedback, feedbackKey, onFeedbackChange])
|
|
282
217
|
|
|
283
218
|
// Reset state when feedback collapses
|
|
284
219
|
const resetState = React.useCallback(() => {
|
|
@@ -288,8 +223,7 @@ export function FeedbackFooter({
|
|
|
288
223
|
setAdditionalPills([])
|
|
289
224
|
setDetailText("")
|
|
290
225
|
setActiveTreeIndex(null)
|
|
291
|
-
|
|
292
|
-
}, [setIsEditing])
|
|
226
|
+
}, [])
|
|
293
227
|
|
|
294
228
|
const handleSentimentClick = React.useCallback(
|
|
295
229
|
(sentiment: "positive" | "negative") => {
|
|
@@ -297,26 +231,10 @@ export function FeedbackFooter({
|
|
|
297
231
|
// Reset chip state when switching sentiment, then expand
|
|
298
232
|
resetState()
|
|
299
233
|
setExpanded(true)
|
|
300
|
-
setSubmitted(false)
|
|
301
|
-
setPersisted(null)
|
|
302
|
-
setIsEditing(true)
|
|
303
234
|
},
|
|
304
|
-
[onFeedbackChange, resetState
|
|
235
|
+
[onFeedbackChange, resetState],
|
|
305
236
|
)
|
|
306
237
|
|
|
307
|
-
/** Open the persisted indicator for editing. */
|
|
308
|
-
const handlePersistedClick = React.useCallback(() => {
|
|
309
|
-
if (!persisted) return
|
|
310
|
-
onFeedbackChange(persisted.sentiment)
|
|
311
|
-
setSelectedTier1(persisted.reasonTop ?? null)
|
|
312
|
-
setSelectedTier2(persisted.reasonSub ?? null)
|
|
313
|
-
setAdditionalPills(persisted.pills ?? [])
|
|
314
|
-
setDetailText(persisted.detail ?? "")
|
|
315
|
-
setExpanded(true)
|
|
316
|
-
setSubmitted(false)
|
|
317
|
-
setIsEditing(true)
|
|
318
|
-
}, [persisted, onFeedbackChange, setIsEditing])
|
|
319
|
-
|
|
320
238
|
const handleTier1Toggle = React.useCallback(
|
|
321
239
|
(chipLabel: string) => {
|
|
322
240
|
if (selectedTier1 === chipLabel) {
|
|
@@ -377,9 +295,6 @@ export function FeedbackFooter({
|
|
|
377
295
|
pills: additionalPills,
|
|
378
296
|
detail: detailText,
|
|
379
297
|
})
|
|
380
|
-
// Show transient "Saved" confirmation
|
|
381
|
-
setSubmitted(true)
|
|
382
|
-
// Collapse expansion but keep sentiment visible
|
|
383
298
|
resetState()
|
|
384
299
|
}, [
|
|
385
300
|
feedback,
|
|
@@ -408,75 +323,38 @@ export function FeedbackFooter({
|
|
|
408
323
|
const activeTree =
|
|
409
324
|
activeTreeIndex !== null ? negativeChips[activeTreeIndex] : null
|
|
410
325
|
|
|
411
|
-
// Determine if we should show the persisted indicator instead of bare buttons
|
|
412
|
-
const showPersistedIndicator = persisted && !expanded && !submitted
|
|
413
|
-
|
|
414
326
|
return (
|
|
415
327
|
<div className={cn("space-y-3", className)}>
|
|
416
328
|
{/* Sentiment buttons + meta text bar */}
|
|
417
329
|
<div className="flex items-center justify-between">
|
|
418
|
-
|
|
419
|
-
/* Persisted feedback indicator — clickable to reopen editor */
|
|
330
|
+
<div className="flex items-center gap-3">
|
|
420
331
|
<button
|
|
421
332
|
type="button"
|
|
422
|
-
onClick={
|
|
423
|
-
className=
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
<ThumbsUp className="h-[11px] w-[11px]" />
|
|
429
|
-
) : (
|
|
430
|
-
<ThumbsDown className="h-[11px] w-[11px]" />
|
|
431
|
-
)}
|
|
432
|
-
{persisted.detail && (
|
|
433
|
-
<span className="max-w-[200px] truncate text-muted-foreground/70">
|
|
434
|
-
{persisted.detail}
|
|
435
|
-
</span>
|
|
333
|
+
onClick={() => handleSentimentClick("positive")}
|
|
334
|
+
className={cn(
|
|
335
|
+
"flex gap-1 items-center text-[11px] font-medium rounded-md px-2 py-1 transition-colors",
|
|
336
|
+
feedback === "positive"
|
|
337
|
+
? SENTIMENT_BUTTON_ACTIVE.positive
|
|
338
|
+
: SENTIMENT_BUTTON_IDLE,
|
|
436
339
|
)}
|
|
437
|
-
|
|
340
|
+
>
|
|
341
|
+
<ThumbsUp className="h-[11px] w-[11px]" />
|
|
342
|
+
Helpful
|
|
438
343
|
</button>
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
? SENTIMENT_BUTTON_ACTIVE.positive
|
|
448
|
-
: SENTIMENT_BUTTON_IDLE,
|
|
449
|
-
)}
|
|
450
|
-
>
|
|
451
|
-
<ThumbsUp className="h-[11px] w-[11px]" />
|
|
452
|
-
Helpful
|
|
453
|
-
</button>
|
|
454
|
-
<button
|
|
455
|
-
type="button"
|
|
456
|
-
onClick={() => handleSentimentClick("negative")}
|
|
457
|
-
className={cn(
|
|
458
|
-
"flex gap-1 items-center text-[11px] font-medium rounded-md px-2 py-1 transition-colors",
|
|
459
|
-
feedback === "negative"
|
|
460
|
-
? SENTIMENT_BUTTON_ACTIVE.negative
|
|
461
|
-
: SENTIMENT_BUTTON_IDLE,
|
|
462
|
-
)}
|
|
463
|
-
>
|
|
464
|
-
<ThumbsDown className="h-[11px] w-[11px]" />
|
|
465
|
-
Not helpful
|
|
466
|
-
</button>
|
|
467
|
-
{/* Transient "Saved" confirmation pill */}
|
|
468
|
-
{submitted && feedback && (
|
|
469
|
-
<span
|
|
470
|
-
className="inline-flex items-center gap-1 text-[11px] font-medium text-emerald-600"
|
|
471
|
-
role="status"
|
|
472
|
-
data-testid="feedback-submitted-pill"
|
|
473
|
-
>
|
|
474
|
-
<Check className="h-[11px] w-[11px]" />
|
|
475
|
-
{submittedLabel}
|
|
476
|
-
</span>
|
|
344
|
+
<button
|
|
345
|
+
type="button"
|
|
346
|
+
onClick={() => handleSentimentClick("negative")}
|
|
347
|
+
className={cn(
|
|
348
|
+
"flex gap-1 items-center text-[11px] font-medium rounded-md px-2 py-1 transition-colors",
|
|
349
|
+
feedback === "negative"
|
|
350
|
+
? SENTIMENT_BUTTON_ACTIVE.negative
|
|
351
|
+
: SENTIMENT_BUTTON_IDLE,
|
|
477
352
|
)}
|
|
478
|
-
|
|
479
|
-
|
|
353
|
+
>
|
|
354
|
+
<ThumbsDown className="h-[11px] w-[11px]" />
|
|
355
|
+
Not helpful
|
|
356
|
+
</button>
|
|
357
|
+
</div>
|
|
480
358
|
{metaText && (
|
|
481
359
|
<span className="text-[11px] text-muted-foreground">{metaText}</span>
|
|
482
360
|
)}
|
|
@@ -544,188 +422,3 @@ export function FeedbackFooter({
|
|
|
544
422
|
</div>
|
|
545
423
|
)
|
|
546
424
|
}
|
|
547
|
-
|
|
548
|
-
// ---------------------------------------------------------------------------
|
|
549
|
-
// InlineFeedbackControl — shared thumb+detail inline feedback widget
|
|
550
|
-
// ---------------------------------------------------------------------------
|
|
551
|
-
|
|
552
|
-
export interface InlineFeedbackControlProps {
|
|
553
|
-
/** Unique key identifying the feedback target (e.g. factor key). */
|
|
554
|
-
feedbackKey: string
|
|
555
|
-
/** Persisted/initial feedback to hydrate from. */
|
|
556
|
-
initialFeedback?: { type: "up" | "down"; detail: string; ownershipLabel?: string }
|
|
557
|
-
/** Called when user submits or clears feedback. */
|
|
558
|
-
onFeedback?: (key: string, type: "up" | "down" | null, detail?: string) => void
|
|
559
|
-
/** Test ID prefix for all sub-elements. */
|
|
560
|
-
testIdPrefix?: string
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
/**
|
|
564
|
-
* Compact inline thumb-up/thumb-down feedback with optional detail text.
|
|
565
|
-
* Used by PriorityFactorRow and any other component that needs
|
|
566
|
-
* a lightweight feedback control.
|
|
567
|
-
*/
|
|
568
|
-
export function InlineFeedbackControl({
|
|
569
|
-
feedbackKey,
|
|
570
|
-
initialFeedback,
|
|
571
|
-
onFeedback,
|
|
572
|
-
testIdPrefix = "inline-feedback",
|
|
573
|
-
}: InlineFeedbackControlProps) {
|
|
574
|
-
const [thumbState, setThumbState] = React.useState<"up" | "down" | null>(
|
|
575
|
-
initialFeedback?.type ?? null,
|
|
576
|
-
)
|
|
577
|
-
const [showInput, setShowInput] = React.useState(false)
|
|
578
|
-
const [detailText, setDetailText] = React.useState(initialFeedback?.detail ?? "")
|
|
579
|
-
const [saved, setSaved] = React.useState(!!initialFeedback)
|
|
580
|
-
const [savedDetail, setSavedDetail] = React.useState(initialFeedback?.detail ?? "")
|
|
581
|
-
const ownershipLabel = initialFeedback?.ownershipLabel ?? "Your feedback"
|
|
582
|
-
|
|
583
|
-
// Sync with initialFeedback prop changes
|
|
584
|
-
React.useEffect(() => {
|
|
585
|
-
if (initialFeedback) {
|
|
586
|
-
setThumbState(initialFeedback.type)
|
|
587
|
-
setSaved(true)
|
|
588
|
-
setSavedDetail(initialFeedback.detail)
|
|
589
|
-
}
|
|
590
|
-
}, [initialFeedback])
|
|
591
|
-
|
|
592
|
-
const handleThumbClick = React.useCallback(
|
|
593
|
-
(type: "up" | "down") => {
|
|
594
|
-
if (thumbState === type) {
|
|
595
|
-
// Toggle off
|
|
596
|
-
setThumbState(null)
|
|
597
|
-
setShowInput(false)
|
|
598
|
-
setSaved(false)
|
|
599
|
-
onFeedback?.(feedbackKey, null)
|
|
600
|
-
} else {
|
|
601
|
-
setThumbState(type)
|
|
602
|
-
setShowInput(true)
|
|
603
|
-
setSaved(false)
|
|
604
|
-
}
|
|
605
|
-
},
|
|
606
|
-
[thumbState, feedbackKey, onFeedback],
|
|
607
|
-
)
|
|
608
|
-
|
|
609
|
-
const handleSubmitDetail = React.useCallback(() => {
|
|
610
|
-
if (!thumbState) return
|
|
611
|
-
const text = detailText.trim()
|
|
612
|
-
onFeedback?.(feedbackKey, thumbState, text)
|
|
613
|
-
setSaved(true)
|
|
614
|
-
setSavedDetail(text)
|
|
615
|
-
setShowInput(false)
|
|
616
|
-
}, [thumbState, detailText, feedbackKey, onFeedback])
|
|
617
|
-
|
|
618
|
-
return (
|
|
619
|
-
<div>
|
|
620
|
-
{saved && !showInput ? (
|
|
621
|
-
/* Persisted / saved indicator */
|
|
622
|
-
<button
|
|
623
|
-
type="button"
|
|
624
|
-
onClick={() => {
|
|
625
|
-
setDetailText(savedDetail)
|
|
626
|
-
setShowInput(true)
|
|
627
|
-
setSaved(false)
|
|
628
|
-
}}
|
|
629
|
-
className="group flex items-center gap-1.5 text-[11px] text-muted-foreground hover:text-foreground transition-colors"
|
|
630
|
-
data-testid={`${testIdPrefix}-feedback-persisted-${feedbackKey}`}
|
|
631
|
-
>
|
|
632
|
-
<span className="font-medium">{ownershipLabel}:</span>
|
|
633
|
-
{thumbState === "up" ? (
|
|
634
|
-
<ThumbsUp className="h-[10px] w-[10px]" />
|
|
635
|
-
) : (
|
|
636
|
-
<ThumbsDown className="h-[10px] w-[10px]" />
|
|
637
|
-
)}
|
|
638
|
-
{savedDetail && (
|
|
639
|
-
<span className="max-w-[180px] truncate text-muted-foreground/70">
|
|
640
|
-
{savedDetail}
|
|
641
|
-
</span>
|
|
642
|
-
)}
|
|
643
|
-
<Pencil className="h-[9px] w-[9px] opacity-0 group-hover:opacity-100 transition-opacity" />
|
|
644
|
-
</button>
|
|
645
|
-
) : (
|
|
646
|
-
<div className="flex items-center gap-1.5">
|
|
647
|
-
{/* Inline thumb buttons */}
|
|
648
|
-
<button
|
|
649
|
-
type="button"
|
|
650
|
-
onClick={() => handleThumbClick("up")}
|
|
651
|
-
className={cn(
|
|
652
|
-
"p-1 rounded transition-colors",
|
|
653
|
-
thumbState === "up"
|
|
654
|
-
? "text-foreground bg-muted"
|
|
655
|
-
: "text-muted-foreground/40 hover:text-foreground hover:bg-muted/50",
|
|
656
|
-
)}
|
|
657
|
-
title="This is accurate"
|
|
658
|
-
data-testid={`${testIdPrefix}-thumb-up-${feedbackKey}`}
|
|
659
|
-
>
|
|
660
|
-
<ThumbsUp className="h-[10px] w-[10px]" />
|
|
661
|
-
</button>
|
|
662
|
-
<button
|
|
663
|
-
type="button"
|
|
664
|
-
onClick={() => handleThumbClick("down")}
|
|
665
|
-
className={cn(
|
|
666
|
-
"p-1 rounded transition-colors",
|
|
667
|
-
thumbState === "down"
|
|
668
|
-
? "text-red-600 bg-red-50"
|
|
669
|
-
: "text-muted-foreground/40 hover:text-red-600 hover:bg-red-50/50",
|
|
670
|
-
)}
|
|
671
|
-
title="Report issue"
|
|
672
|
-
data-testid={`${testIdPrefix}-thumb-down-${feedbackKey}`}
|
|
673
|
-
>
|
|
674
|
-
<ThumbsDown className="h-[10px] w-[10px]" />
|
|
675
|
-
</button>
|
|
676
|
-
|
|
677
|
-
{/* Transient "Saved" pill */}
|
|
678
|
-
{saved && (
|
|
679
|
-
<span
|
|
680
|
-
className="inline-flex items-center gap-1 text-[11px] font-medium text-emerald-600"
|
|
681
|
-
role="status"
|
|
682
|
-
data-testid={`${testIdPrefix}-saved-${feedbackKey}`}
|
|
683
|
-
>
|
|
684
|
-
<Check className="h-[10px] w-[10px]" />
|
|
685
|
-
Saved
|
|
686
|
-
</span>
|
|
687
|
-
)}
|
|
688
|
-
</div>
|
|
689
|
-
)}
|
|
690
|
-
|
|
691
|
-
{/* Inline detail input */}
|
|
692
|
-
{showInput && thumbState && (
|
|
693
|
-
<div className="mt-1.5">
|
|
694
|
-
<input
|
|
695
|
-
type="text"
|
|
696
|
-
value={detailText}
|
|
697
|
-
onChange={(e) => setDetailText(e.target.value)}
|
|
698
|
-
onKeyDown={(e) => {
|
|
699
|
-
if (e.key === "Enter") handleSubmitDetail()
|
|
700
|
-
if (e.key === "Escape") setShowInput(false)
|
|
701
|
-
}}
|
|
702
|
-
placeholder={
|
|
703
|
-
thumbState === "up"
|
|
704
|
-
? "What\u2019s accurate? (optional)"
|
|
705
|
-
: "What\u2019s wrong? (optional)"
|
|
706
|
-
}
|
|
707
|
-
className="w-full h-6 rounded border border-border bg-background px-2 text-[11px] text-foreground placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-ring"
|
|
708
|
-
data-testid={`${testIdPrefix}-detail-input-${feedbackKey}`}
|
|
709
|
-
/>
|
|
710
|
-
<div className="mt-1 flex items-center gap-1.5">
|
|
711
|
-
<button
|
|
712
|
-
type="button"
|
|
713
|
-
onClick={handleSubmitDetail}
|
|
714
|
-
className="bg-foreground text-background rounded px-2 py-0.5 text-[10px] font-semibold"
|
|
715
|
-
data-testid={`${testIdPrefix}-submit-${feedbackKey}`}
|
|
716
|
-
>
|
|
717
|
-
Submit
|
|
718
|
-
</button>
|
|
719
|
-
<button
|
|
720
|
-
type="button"
|
|
721
|
-
onClick={() => setShowInput(false)}
|
|
722
|
-
className="border border-border rounded px-2 py-0.5 text-[10px] font-medium"
|
|
723
|
-
>
|
|
724
|
-
Cancel
|
|
725
|
-
</button>
|
|
726
|
-
</div>
|
|
727
|
-
</div>
|
|
728
|
-
)}
|
|
729
|
-
</div>
|
|
730
|
-
)
|
|
731
|
-
}
|