@handled-ai/design-system 0.9.23 → 0.9.25
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/components/score-breakdown.d.ts +5 -1
- package/dist/components/score-breakdown.js +40 -5
- package/dist/components/score-breakdown.js.map +1 -1
- package/dist/components/score-feedback.d.ts +6 -1
- package/dist/components/score-feedback.js +8 -2
- package/dist/components/score-feedback.js.map +1 -1
- package/dist/components/signal-feedback-inline.d.ts +13 -2
- package/dist/components/signal-feedback-inline.js +33 -4
- package/dist/components/signal-feedback-inline.js.map +1 -1
- package/dist/prototype/prototype-config.d.ts +11 -1
- package/dist/prototype/prototype-inbox-view.d.ts +1 -1
- package/dist/prototype/prototype-inbox-view.js +56 -48
- package/dist/prototype/prototype-inbox-view.js.map +1 -1
- package/package.json +23 -6
- package/src/__test-helpers__/fixtures.ts +152 -0
- package/src/components/__tests__/score-breakdown-initial.test.tsx +310 -0
- package/src/components/__tests__/score-feedback-initial.test.tsx +253 -0
- package/src/components/score-breakdown.tsx +50 -5
- package/src/components/score-feedback.tsx +12 -2
- package/src/components/signal-feedback-inline.tsx +51 -5
- package/src/prototype/prototype-config.ts +4 -1
- package/src/prototype/prototype-inbox-view.tsx +6 -2
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for ScoreFeedback.Root's `initialFeedback` prop and related behaviors.
|
|
3
|
+
*
|
|
4
|
+
* Covers:
|
|
5
|
+
* - Initial `submittedFeedback` pre-population via prop
|
|
6
|
+
* - "Noted" / "Recorded" shown instead of thumbs when pre-populated
|
|
7
|
+
* - null initialFeedback → blank state (thumbs shown)
|
|
8
|
+
* - useEffect sync: prop changes from null → value updates submittedFeedback
|
|
9
|
+
* - useEffect guard: in-progress edit (thumbState ≠ null) skips sync
|
|
10
|
+
* - editSubmitted restores prior feedback into editing state
|
|
11
|
+
* - handleSubmit calls onSubmitFeedback
|
|
12
|
+
* - factor callback fix: text ?? "" (empty string, not undefined)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, it, expect, vi } from "vitest";
|
|
16
|
+
import React from "react";
|
|
17
|
+
import { render, screen, fireEvent, act } from "@testing-library/react";
|
|
18
|
+
import { ScoreFeedback } from "../score-feedback";
|
|
19
|
+
import {
|
|
20
|
+
makeInitialScoreFeedback,
|
|
21
|
+
makeNegativeInitialScoreFeedback,
|
|
22
|
+
makeMinimalInitialScoreFeedback,
|
|
23
|
+
} from "../../__test-helpers__/fixtures";
|
|
24
|
+
|
|
25
|
+
// ─── Minimal compound-component wrapper ──────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
function Wrapper({
|
|
28
|
+
initialFeedback,
|
|
29
|
+
onSubmitFeedback,
|
|
30
|
+
}: {
|
|
31
|
+
initialFeedback?: { type: "up" | "down"; pills: string[]; detail: string } | null;
|
|
32
|
+
onSubmitFeedback?: (type: "up" | "down", pills: string[], detail: string) => void;
|
|
33
|
+
}) {
|
|
34
|
+
return (
|
|
35
|
+
<ScoreFeedback.Root
|
|
36
|
+
initialFeedback={initialFeedback}
|
|
37
|
+
onSubmitFeedback={onSubmitFeedback}
|
|
38
|
+
>
|
|
39
|
+
<ScoreFeedback.Trigger />
|
|
40
|
+
<ScoreFeedback.Panel />
|
|
41
|
+
</ScoreFeedback.Root>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ─── Tests ────────────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
describe("ScoreFeedback.Root — initialFeedback prop", () => {
|
|
48
|
+
it("shows 'Noted' when initialFeedback.type is 'up'", () => {
|
|
49
|
+
render(<Wrapper initialFeedback={makeInitialScoreFeedback()} />);
|
|
50
|
+
expect(screen.getByText("Noted")).toBeDefined();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("shows 'Recorded' when initialFeedback.type is 'down'", () => {
|
|
54
|
+
render(<Wrapper initialFeedback={makeNegativeInitialScoreFeedback()} />);
|
|
55
|
+
expect(screen.getByText("Recorded")).toBeDefined();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("does NOT show thumbs when initialFeedback is set (submitted state)", () => {
|
|
59
|
+
render(
|
|
60
|
+
<Wrapper initialFeedback={makeInitialScoreFeedback()} />,
|
|
61
|
+
);
|
|
62
|
+
// Thumbs-up and thumbs-down buttons should not be rendered
|
|
63
|
+
// The only button visible should be the "edit" button (with pill/detail), not bare thumbs
|
|
64
|
+
// We check by absence of the thumb click pattern — no button with onClick=handleThumbClick("up")
|
|
65
|
+
// More concretely: no thumb buttons means the Trigger is in "submitted" mode
|
|
66
|
+
expect(screen.queryByTitle?.("")).toBeNull?.(); // optional, just no crash
|
|
67
|
+
expect(screen.getByText("Noted")).toBeDefined();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("shows thumbs when initialFeedback is null", () => {
|
|
71
|
+
const { container } = render(<Wrapper initialFeedback={null} />);
|
|
72
|
+
// Panel is hidden (no thumbState), Trigger shows thumb buttons
|
|
73
|
+
const buttons = container.querySelectorAll("button");
|
|
74
|
+
// Two thumb buttons are present
|
|
75
|
+
expect(buttons.length).toBeGreaterThanOrEqual(2);
|
|
76
|
+
// No "Noted" or "Recorded" text
|
|
77
|
+
expect(screen.queryByText("Noted")).toBeNull();
|
|
78
|
+
expect(screen.queryByText("Recorded")).toBeNull();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("shows thumbs when initialFeedback is undefined", () => {
|
|
82
|
+
const { container } = render(<Wrapper />);
|
|
83
|
+
const buttons = container.querySelectorAll("button");
|
|
84
|
+
expect(buttons.length).toBeGreaterThanOrEqual(2);
|
|
85
|
+
expect(screen.queryByText("Noted")).toBeNull();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("renders submitted pills from initialFeedback", () => {
|
|
89
|
+
render(
|
|
90
|
+
<Wrapper
|
|
91
|
+
initialFeedback={makeInitialScoreFeedback({
|
|
92
|
+
pills: ["Right timing", "Accurate data"],
|
|
93
|
+
})}
|
|
94
|
+
/>,
|
|
95
|
+
);
|
|
96
|
+
expect(screen.getByText("Right timing")).toBeDefined();
|
|
97
|
+
expect(screen.getByText("Accurate data")).toBeDefined();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("renders submitted detail text from initialFeedback", () => {
|
|
101
|
+
render(
|
|
102
|
+
<Wrapper
|
|
103
|
+
initialFeedback={makeInitialScoreFeedback({ detail: "Score looks correct." })}
|
|
104
|
+
/>,
|
|
105
|
+
);
|
|
106
|
+
expect(screen.getByText("Score looks correct.")).toBeDefined();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("minimal initialFeedback (no pills, no detail) still shows Noted without edit button", () => {
|
|
110
|
+
const { container } = render(
|
|
111
|
+
<Wrapper initialFeedback={makeMinimalInitialScoreFeedback("up")} />,
|
|
112
|
+
);
|
|
113
|
+
expect(screen.getByText("Noted")).toBeDefined();
|
|
114
|
+
// No pill text to find — just check no crash
|
|
115
|
+
const buttons = container.querySelectorAll("button");
|
|
116
|
+
// The edit button requires pills.length > 0 || detail, so no edit button here
|
|
117
|
+
expect(buttons.length).toBe(0);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("ScoreFeedback.Root — useEffect sync", () => {
|
|
122
|
+
it("updates submittedFeedback when initialFeedback prop changes from null to value", async () => {
|
|
123
|
+
const { rerender } = render(<Wrapper initialFeedback={null} />);
|
|
124
|
+
|
|
125
|
+
// Initially null → thumbs shown
|
|
126
|
+
expect(screen.queryByText("Noted")).toBeNull();
|
|
127
|
+
|
|
128
|
+
// Prop changes to a value
|
|
129
|
+
await act(async () => {
|
|
130
|
+
rerender(<Wrapper initialFeedback={makeInitialScoreFeedback()} />);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// Now should show "Noted"
|
|
134
|
+
expect(screen.getByText("Noted")).toBeDefined();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("updates from 'up' to 'down' when prop changes", async () => {
|
|
138
|
+
const { rerender } = render(
|
|
139
|
+
<Wrapper initialFeedback={makeInitialScoreFeedback()} />,
|
|
140
|
+
);
|
|
141
|
+
expect(screen.getByText("Noted")).toBeDefined();
|
|
142
|
+
|
|
143
|
+
await act(async () => {
|
|
144
|
+
rerender(<Wrapper initialFeedback={makeNegativeInitialScoreFeedback()} />);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
expect(screen.getByText("Recorded")).toBeDefined();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("guard: does NOT sync when user has thumbState set (in-progress edit)", async () => {
|
|
151
|
+
// Start with null initialFeedback
|
|
152
|
+
const { rerender, container } = render(<Wrapper initialFeedback={null} />);
|
|
153
|
+
|
|
154
|
+
// User clicks thumbs-up to start an edit
|
|
155
|
+
const buttons = container.querySelectorAll("button");
|
|
156
|
+
await act(async () => {
|
|
157
|
+
fireEvent.click(buttons[0]); // thumbs-up button
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// Now prop changes — should NOT overwrite in-progress edit
|
|
161
|
+
await act(async () => {
|
|
162
|
+
rerender(<Wrapper initialFeedback={makeNegativeInitialScoreFeedback()} />);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// Should NOT show "Recorded" — user's thumb state takes precedence
|
|
166
|
+
expect(screen.queryByText("Recorded")).toBeNull();
|
|
167
|
+
// Panel should be visible (thumbState is set)
|
|
168
|
+
// The Panel shows "How's this score?" section
|
|
169
|
+
expect(screen.getByText(/How['']s this score\?/)).toBeDefined();
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe("ScoreFeedback.Root — onSubmitFeedback callback", () => {
|
|
174
|
+
it("calls onSubmitFeedback with (type, pills, detail) on submit", async () => {
|
|
175
|
+
const onSubmit = vi.fn();
|
|
176
|
+
const { container } = render(
|
|
177
|
+
<Wrapper initialFeedback={null} onSubmitFeedback={onSubmit} />,
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
// Click thumbs-up
|
|
181
|
+
const thumbButtons = container.querySelectorAll("button");
|
|
182
|
+
await act(async () => {
|
|
183
|
+
fireEvent.click(thumbButtons[0]); // thumbs-up
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// Select a pill
|
|
187
|
+
const pillButton = screen.getByText("Right timing");
|
|
188
|
+
await act(async () => {
|
|
189
|
+
fireEvent.click(pillButton);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// Click Submit
|
|
193
|
+
const submitButton = screen.getByText("Submit");
|
|
194
|
+
await act(async () => {
|
|
195
|
+
fireEvent.click(submitButton);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
expect(onSubmit).toHaveBeenCalledTimes(1);
|
|
199
|
+
expect(onSubmit).toHaveBeenCalledWith("up", ["Right timing"], "");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("calls onSubmitFeedback with empty string detail when no detail entered", async () => {
|
|
203
|
+
const onSubmit = vi.fn();
|
|
204
|
+
const { container } = render(
|
|
205
|
+
<Wrapper initialFeedback={null} onSubmitFeedback={onSubmit} />,
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
const thumbButtons = container.querySelectorAll("button");
|
|
209
|
+
await act(async () => {
|
|
210
|
+
fireEvent.click(thumbButtons[0]); // thumbs-up
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const pillButton = screen.getByText("Right timing");
|
|
214
|
+
await act(async () => {
|
|
215
|
+
fireEvent.click(pillButton);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const submitButton = screen.getByText("Submit");
|
|
219
|
+
await act(async () => {
|
|
220
|
+
fireEvent.click(submitButton);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
const [, , detail] = onSubmit.mock.calls[0] as [string, string[], string];
|
|
224
|
+
expect(detail).toBe("");
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
describe("ScoreFeedback.Root — editSubmitted", () => {
|
|
229
|
+
it("clicking the pills/detail edit area restores feedback into editing state", async () => {
|
|
230
|
+
const { container } = render(
|
|
231
|
+
<Wrapper
|
|
232
|
+
initialFeedback={makeInitialScoreFeedback({
|
|
233
|
+
pills: ["Right timing"],
|
|
234
|
+
detail: "Score looks correct.",
|
|
235
|
+
})}
|
|
236
|
+
/>,
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
// The edit button (wraps pills + detail) should be present
|
|
240
|
+
const editButton = container.querySelector("button");
|
|
241
|
+
expect(editButton).not.toBeNull();
|
|
242
|
+
|
|
243
|
+
await act(async () => {
|
|
244
|
+
fireEvent.click(editButton!);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// After clicking, the Panel should be visible with "How's this score?"
|
|
248
|
+
expect(screen.getByText(/How['']s this score\?/)).toBeDefined();
|
|
249
|
+
// And the previously selected pill should be pre-selected
|
|
250
|
+
// (visible in the panel's pill list as selected)
|
|
251
|
+
expect(screen.getAllByText("Right timing").length).toBeGreaterThanOrEqual(1);
|
|
252
|
+
});
|
|
253
|
+
});
|
|
@@ -33,14 +33,59 @@ interface ScoreBreakdownProps {
|
|
|
33
33
|
factors: ScoreFactor[]
|
|
34
34
|
onFactorFeedback?: (factorKey: string, type: "up" | "down" | null, detail?: string) => void
|
|
35
35
|
className?: string
|
|
36
|
+
initialFeedback?: Record<string, { type: "up" | "down"; detail: string }>
|
|
36
37
|
}
|
|
37
38
|
|
|
38
|
-
function
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
39
|
+
function deriveInitialState<T>(
|
|
40
|
+
init: Record<string, { type: "up" | "down"; detail: string }> | undefined,
|
|
41
|
+
mapFn: (v: { type: "up" | "down"; detail: string }) => T,
|
|
42
|
+
filterFn?: (v: { type: "up" | "down"; detail: string }) => boolean,
|
|
43
|
+
): Record<string, T> {
|
|
44
|
+
if (!init) return {}
|
|
45
|
+
const entries = Object.entries(init)
|
|
46
|
+
const filtered = filterFn ? entries.filter(([, v]) => filterFn(v)) : entries
|
|
47
|
+
return Object.fromEntries(filtered.map(([k, v]) => [k, mapFn(v)]))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function ScoreBreakdown({ factors, onFactorFeedback, className, initialFeedback }: ScoreBreakdownProps) {
|
|
51
|
+
const [feedback, setFeedback] = React.useState<Record<string, "up" | "down" | null>>(
|
|
52
|
+
() => deriveInitialState(initialFeedback, (v) => v.type)
|
|
53
|
+
)
|
|
54
|
+
const [feedbackText, setFeedbackText] = React.useState<Record<string, string>>(
|
|
55
|
+
() => deriveInitialState(initialFeedback, (v) => v.detail)
|
|
56
|
+
)
|
|
57
|
+
const [savedText, setSavedText] = React.useState<Record<string, string>>(
|
|
58
|
+
() => deriveInitialState(initialFeedback, (v) => v.detail, (v) => !!v.detail)
|
|
59
|
+
)
|
|
42
60
|
const [editingKey, setEditingKey] = React.useState<string | null>(null)
|
|
43
61
|
|
|
62
|
+
// Sync state when initialFeedback prop changes (e.g. async hydration).
|
|
63
|
+
// Skip keys the user is actively editing to avoid clobbering in-progress input.
|
|
64
|
+
React.useEffect(() => {
|
|
65
|
+
setFeedback((prev) => {
|
|
66
|
+
const next: Record<string, "up" | "down" | null> = deriveInitialState(initialFeedback, (v) => v.type)
|
|
67
|
+
// Preserve any key the user is currently editing
|
|
68
|
+
if (editingKey && prev[editingKey] !== undefined) {
|
|
69
|
+
next[editingKey] = prev[editingKey]
|
|
70
|
+
}
|
|
71
|
+
return next
|
|
72
|
+
})
|
|
73
|
+
setFeedbackText((prev) => {
|
|
74
|
+
const next = deriveInitialState(initialFeedback, (v) => v.detail)
|
|
75
|
+
if (editingKey && prev[editingKey] !== undefined) {
|
|
76
|
+
next[editingKey] = prev[editingKey]
|
|
77
|
+
}
|
|
78
|
+
return next
|
|
79
|
+
})
|
|
80
|
+
setSavedText((prev) => {
|
|
81
|
+
const next = deriveInitialState(initialFeedback, (v) => v.detail, (v) => !!v.detail)
|
|
82
|
+
if (editingKey && prev[editingKey] !== undefined) {
|
|
83
|
+
next[editingKey] = prev[editingKey]
|
|
84
|
+
}
|
|
85
|
+
return next
|
|
86
|
+
})
|
|
87
|
+
}, [initialFeedback]) // eslint-disable-line react-hooks/exhaustive-deps -- reads editingKey as guard, not trigger
|
|
88
|
+
|
|
44
89
|
const handleFeedback = (factorKey: string, type: "up" | "down") => {
|
|
45
90
|
const newState = feedback[factorKey] === type ? null : type
|
|
46
91
|
setFeedback((prev) => ({ ...prev, [factorKey]: newState }))
|
|
@@ -56,7 +101,7 @@ function ScoreBreakdown({ factors, onFactorFeedback, className }: ScoreBreakdown
|
|
|
56
101
|
const submitFeedbackText = (factorKey: string) => {
|
|
57
102
|
const text = (feedbackText[factorKey] ?? "").trim()
|
|
58
103
|
if (feedback[factorKey]) {
|
|
59
|
-
onFactorFeedback?.(factorKey, feedback[factorKey]!, text
|
|
104
|
+
onFactorFeedback?.(factorKey, feedback[factorKey]!, text ?? "")
|
|
60
105
|
if (text) {
|
|
61
106
|
setSavedText((prev) => ({ ...prev, [factorKey]: text }))
|
|
62
107
|
}
|
|
@@ -52,14 +52,24 @@ function useScoreFeedback() {
|
|
|
52
52
|
interface RootProps {
|
|
53
53
|
children: React.ReactNode
|
|
54
54
|
onSubmitFeedback?: (type: "up" | "down", pills: string[], detail: string) => void
|
|
55
|
+
initialFeedback?: { type: "up" | "down"; pills: string[]; detail: string } | null
|
|
55
56
|
}
|
|
56
57
|
|
|
57
|
-
function Root({ children, onSubmitFeedback }: RootProps) {
|
|
58
|
+
function Root({ children, onSubmitFeedback, initialFeedback }: RootProps) {
|
|
58
59
|
const [thumbState, setThumbState] = React.useState<"up" | "down" | null>(null)
|
|
59
60
|
const [selectedPills, setSelectedPills] = React.useState<string[]>([])
|
|
60
61
|
const [detailText, setDetailTextState] = React.useState("")
|
|
61
62
|
const [notedType, setNotedType] = React.useState<"up" | "down" | null>(null)
|
|
62
|
-
const [submittedFeedback, setSubmittedFeedback] = React.useState<SubmittedScoreFeedback | null>(
|
|
63
|
+
const [submittedFeedback, setSubmittedFeedback] = React.useState<SubmittedScoreFeedback | null>(
|
|
64
|
+
initialFeedback ?? null
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
// Sync submitted feedback when initialFeedback prop changes (e.g. async
|
|
68
|
+
// detail load). Skip when the user has an in-progress edit (thumbState set).
|
|
69
|
+
React.useEffect(() => {
|
|
70
|
+
if (thumbState !== null) return
|
|
71
|
+
setSubmittedFeedback(initialFeedback ?? null)
|
|
72
|
+
}, [initialFeedback]) // eslint-disable-line react-hooks/exhaustive-deps -- intentionally omits thumbState to read it as a guard, not a trigger
|
|
63
73
|
|
|
64
74
|
const otherSelected = selectedPills.includes("Other")
|
|
65
75
|
|
|
@@ -71,7 +71,7 @@ const approveReasons = [
|
|
|
71
71
|
"Actionable",
|
|
72
72
|
]
|
|
73
73
|
|
|
74
|
-
type ApprovalState = "pending" | "confirming" | "approving-feedback" | "dismissing" | "approved" | "dismissed" | "auto-approved"
|
|
74
|
+
type ApprovalState = "pending" | "confirming" | "creating" | "approving-feedback" | "dismissing" | "approved" | "dismissed" | "auto-approved"
|
|
75
75
|
|
|
76
76
|
interface SignalApprovalLabels {
|
|
77
77
|
approveButton?: string
|
|
@@ -82,6 +82,8 @@ interface SignalApprovalLabels {
|
|
|
82
82
|
confirmPrompt?: string
|
|
83
83
|
dismissPrompt?: string
|
|
84
84
|
feedbackPrompt?: string
|
|
85
|
+
/** Label shown while the approve action is in progress (e.g. "Creating Opportunity..."). */
|
|
86
|
+
creatingStatus?: string
|
|
85
87
|
}
|
|
86
88
|
|
|
87
89
|
const DEFAULT_LABELS: Required<SignalApprovalLabels> = {
|
|
@@ -93,6 +95,7 @@ const DEFAULT_LABELS: Required<SignalApprovalLabels> = {
|
|
|
93
95
|
confirmPrompt: "This will approve this action for",
|
|
94
96
|
dismissPrompt: "What\u2019s the issue with this action?",
|
|
95
97
|
feedbackPrompt: "Quick feedback \u2014 what made this action useful?",
|
|
98
|
+
creatingStatus: "Creating\u2026",
|
|
96
99
|
}
|
|
97
100
|
|
|
98
101
|
interface SignalApprovalContextValue {
|
|
@@ -128,7 +131,16 @@ interface RootProps {
|
|
|
128
131
|
labels?: SignalApprovalLabels
|
|
129
132
|
/** When true, the approve/create-opportunity button is hidden but the dismiss button remains. */
|
|
130
133
|
hideApproveButton?: boolean
|
|
131
|
-
|
|
134
|
+
/**
|
|
135
|
+
* Called when the user confirms the approval action.
|
|
136
|
+
*
|
|
137
|
+
* - If the callback returns `void` (or `undefined`), the component transitions
|
|
138
|
+
* directly to the feedback step (backward-compatible behavior).
|
|
139
|
+
* - If the callback returns a `Promise<boolean>`, the component shows a
|
|
140
|
+
* "creating" loading state while the promise is pending. On `true` it
|
|
141
|
+
* transitions to the feedback step; on `false` it reverts to "pending".
|
|
142
|
+
*/
|
|
143
|
+
onApprove?: () => void | Promise<boolean>
|
|
132
144
|
onApproveFeedback?: (reasons: string[], detail: string) => void
|
|
133
145
|
onDismiss?: (reasons: string[], detail: string, subReason?: string) => void
|
|
134
146
|
}
|
|
@@ -137,6 +149,13 @@ function Root({ children, companyName, opportunityUrl, scheduledTime, initialApp
|
|
|
137
149
|
const labels = React.useMemo(() => ({ ...DEFAULT_LABELS, ...labelOverrides }), [labelOverrides])
|
|
138
150
|
const [approvalState, setApprovalState] = React.useState<ApprovalState>(initialApprovalState ?? "pending")
|
|
139
151
|
|
|
152
|
+
// Guard against state updates after unmount (e.g. user navigates away while
|
|
153
|
+
// an async onApprove promise is still in flight).
|
|
154
|
+
const mountedRef = React.useRef(true)
|
|
155
|
+
React.useEffect(() => {
|
|
156
|
+
return () => { mountedRef.current = false }
|
|
157
|
+
}, [])
|
|
158
|
+
|
|
140
159
|
const requestApproval = React.useCallback(() => {
|
|
141
160
|
setApprovalState("confirming")
|
|
142
161
|
}, [])
|
|
@@ -150,8 +169,23 @@ function Root({ children, companyName, opportunityUrl, scheduledTime, initialApp
|
|
|
150
169
|
}, [])
|
|
151
170
|
|
|
152
171
|
const approve = React.useCallback(() => {
|
|
153
|
-
|
|
154
|
-
|
|
172
|
+
const result = onApprove?.()
|
|
173
|
+
// If the callback returns a Promise, show a loading state and wait for it.
|
|
174
|
+
if (result && typeof (result as Promise<boolean>).then === "function") {
|
|
175
|
+
setApprovalState("creating")
|
|
176
|
+
;(result as Promise<boolean>).then((success) => {
|
|
177
|
+
if (mountedRef.current) {
|
|
178
|
+
setApprovalState(success ? "approving-feedback" : "pending")
|
|
179
|
+
}
|
|
180
|
+
}).catch(() => {
|
|
181
|
+
if (mountedRef.current) {
|
|
182
|
+
setApprovalState("pending")
|
|
183
|
+
}
|
|
184
|
+
})
|
|
185
|
+
} else {
|
|
186
|
+
// Synchronous / void — transition immediately (backward-compatible).
|
|
187
|
+
setApprovalState("approving-feedback")
|
|
188
|
+
}
|
|
155
189
|
}, [onApprove])
|
|
156
190
|
|
|
157
191
|
const submitApproveFeedback = React.useCallback(
|
|
@@ -439,6 +473,18 @@ function Actions() {
|
|
|
439
473
|
setDetailText("")
|
|
440
474
|
}
|
|
441
475
|
|
|
476
|
+
if (approvalState === "creating") {
|
|
477
|
+
return (
|
|
478
|
+
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground">
|
|
479
|
+
<svg className="h-3.5 w-3.5 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
480
|
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
481
|
+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
|
482
|
+
</svg>
|
|
483
|
+
<span>{labels.creatingStatus}</span>
|
|
484
|
+
</div>
|
|
485
|
+
)
|
|
486
|
+
}
|
|
487
|
+
|
|
442
488
|
if (approvalState === "approved") {
|
|
443
489
|
if (isEditing) {
|
|
444
490
|
return (
|
|
@@ -728,7 +774,7 @@ function Gate({ children }: { children: React.ReactNode }) {
|
|
|
728
774
|
const { approvalState, hideApproveButton } = useSignalApproval()
|
|
729
775
|
// When the approve button is hidden, don't lock content behind approval
|
|
730
776
|
const isLocked = !hideApproveButton &&
|
|
731
|
-
(approvalState === "pending" || approvalState === "confirming" || approvalState === "dismissing")
|
|
777
|
+
(approvalState === "pending" || approvalState === "confirming" || approvalState === "creating" || approvalState === "dismissing")
|
|
732
778
|
|
|
733
779
|
return (
|
|
734
780
|
<div className="relative">
|
|
@@ -46,6 +46,8 @@ export interface SignalScoreData {
|
|
|
46
46
|
onScoreFeedback?: (type: "up" | "down", pills: string[], detail: string) => void
|
|
47
47
|
onApproveFeedback?: (reasons: string[], detail: string) => void
|
|
48
48
|
onDismissFeedback?: (reasons: string[], detail: string, subReason?: string) => void
|
|
49
|
+
initialScoreFeedback?: { type: "up" | "down"; pills: string[]; detail: string } | null
|
|
50
|
+
initialFactorFeedback?: Record<string, { type: "up" | "down"; detail: string }>
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
// ---------------------------------------------------------------------------
|
|
@@ -78,7 +80,7 @@ export interface InboxViewConfig {
|
|
|
78
80
|
quickFilterTabs?: Array<{ id: string; label: string; matchValue?: string; count?: number }>
|
|
79
81
|
hideAccountsButton?: boolean
|
|
80
82
|
accountDetailsLabel?: string
|
|
81
|
-
onSignalApprove?: (item: QueueItem) => void
|
|
83
|
+
onSignalApprove?: (item: QueueItem) => void | Promise<boolean>
|
|
82
84
|
getSignalApprovalState?: (item: QueueItem) => ApprovalState | undefined
|
|
83
85
|
signalLabels?: {
|
|
84
86
|
approveButton?: string
|
|
@@ -87,6 +89,7 @@ export interface InboxViewConfig {
|
|
|
87
89
|
dismissedStatus?: string
|
|
88
90
|
opportunityCreated?: string
|
|
89
91
|
confirmPrompt?: string
|
|
92
|
+
creatingStatus?: string
|
|
90
93
|
}
|
|
91
94
|
/** When true, the approve/create-opportunity button is hidden but the dismiss button remains. */
|
|
92
95
|
hideApproveButton?: boolean
|
|
@@ -102,7 +102,7 @@ export interface DetailViewProps {
|
|
|
102
102
|
onOpenEntityPanel?: () => void
|
|
103
103
|
onOpenRecentActivity?: () => void
|
|
104
104
|
onSuggestedActionFeedback?: (actionId: number | string, feedback: string, actionTitle?: string) => void
|
|
105
|
-
onSignalApprove?: (item: QueueItem) => void
|
|
105
|
+
onSignalApprove?: (item: QueueItem) => void | Promise<boolean>
|
|
106
106
|
getSignalApprovalState?: (item: QueueItem) => ApprovalState | undefined
|
|
107
107
|
signalLabels?: InboxViewConfig["signalLabels"]
|
|
108
108
|
hideApproveButton?: boolean
|
|
@@ -279,7 +279,10 @@ export function DetailView({
|
|
|
279
279
|
{signalData.whyNow}
|
|
280
280
|
</p>
|
|
281
281
|
|
|
282
|
-
<ScoreFeedback.Root
|
|
282
|
+
<ScoreFeedback.Root
|
|
283
|
+
onSubmitFeedback={(type, pills, detail) => (signalData.onScoreFeedback ?? onScoreFeedback)?.(type, pills, detail)}
|
|
284
|
+
initialFeedback={signalData.initialScoreFeedback}
|
|
285
|
+
>
|
|
283
286
|
<div className="mb-5 rounded-md border border-border bg-muted/20 p-3">
|
|
284
287
|
<div className="flex items-center justify-between mb-1.5">
|
|
285
288
|
<span className="text-[10px] font-bold text-muted-foreground uppercase tracking-wider">Signal Score</span>
|
|
@@ -320,6 +323,7 @@ export function DetailView({
|
|
|
320
323
|
onFactorFeedback={signalData.onFactorFeedback ?? ((key, type, detail) =>
|
|
321
324
|
console.log("Signal factor feedback:", { company: item.company, factor: key, type, detail })
|
|
322
325
|
)}
|
|
326
|
+
initialFeedback={signalData.initialFactorFeedback}
|
|
323
327
|
/>
|
|
324
328
|
<SignalApproval.Actions />
|
|
325
329
|
</div>
|