@handled-ai/design-system 0.18.44 → 0.18.46

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.
@@ -149,4 +149,63 @@ describe("CasePanelSignalApprovalActions", () => {
149
149
  expect(screen.getByText(/this will approve this action for/i)).toBeInTheDocument()
150
150
  expect(screen.getByRole("button", { name: /confirm/i }).className).toContain("h-7")
151
151
  })
152
+
153
+ it("lets callers collect edited opportunity preview fields before approval", async () => {
154
+ const onApprove = vi.fn()
155
+
156
+ renderApprovalActions({
157
+ initialApprovalState: "confirming",
158
+ onApprove,
159
+ opportunityPreview: {
160
+ name: "Churn Risk - Northwind Systems",
161
+ accountName: "Northwind Systems",
162
+ stage: "Prospecting",
163
+ closeDate: "Jun 30, 2026",
164
+ closeDateValue: "2026-06-30",
165
+ amount: "$75,000",
166
+ amountValue: 75000,
167
+ description: "Initial description",
168
+ churnType: "Churn Risk",
169
+ churnTypeOptions: ["Churn Risk", { value: "Win Back", label: "Win-back" }],
170
+ },
171
+ })
172
+
173
+ fireEvent.change(screen.getByLabelText("Close Date"), { target: { value: "2026-07-15" } })
174
+ expect(screen.getByLabelText("Amount")).toHaveValue("$75,000")
175
+ fireEvent.change(screen.getByLabelText("Amount"), { target: { value: "90000" } })
176
+ fireEvent.change(screen.getByLabelText("Churn Type"), { target: { value: "Win Back" } })
177
+ fireEvent.change(screen.getByLabelText("Description"), { target: { value: "Updated before create" } })
178
+ fireEvent.click(screen.getByRole("button", { name: /confirm/i }))
179
+
180
+ expect(onApprove).toHaveBeenCalledWith({
181
+ closeDate: "2026-07-15",
182
+ amount: "90000",
183
+ churnType: "Win Back",
184
+ description: "Updated before create",
185
+ })
186
+ })
187
+
188
+ it("blocks confirmation until the close date is valid", () => {
189
+ const onApprove = vi.fn()
190
+
191
+ renderApprovalActions({
192
+ initialApprovalState: "confirming",
193
+ onApprove,
194
+ opportunityPreview: {
195
+ name: "Churn Risk - Northwind Systems",
196
+ accountName: "Northwind Systems",
197
+ stage: "Prospecting",
198
+ closeDate: "Jun 30, 2026",
199
+ closeDateValue: "2026-06-30",
200
+ amount: "$75,000",
201
+ },
202
+ })
203
+
204
+ fireEvent.change(screen.getByLabelText("Close Date"), { target: { value: "" } })
205
+ expect(screen.getByRole("button", { name: /confirm/i })).toBeDisabled()
206
+ expect(screen.getByText("Enter a valid close date.")).toBeInTheDocument()
207
+
208
+ fireEvent.click(screen.getByRole("button", { name: /confirm/i }))
209
+ expect(onApprove).not.toHaveBeenCalled()
210
+ })
152
211
  })
@@ -73,6 +73,32 @@ const approveReasons = [
73
73
 
74
74
  type ApprovalState = "pending" | "confirming" | "creating" | "approving-feedback" | "dismissing" | "approved" | "dismissed" | "auto-approved"
75
75
 
76
+ interface OpportunityPreviewOption {
77
+ value: string
78
+ label: string
79
+ }
80
+
81
+ interface OpportunityPreview {
82
+ name: string
83
+ stage: string
84
+ closeDate: string
85
+ closeDateValue?: string
86
+ amount: string
87
+ /** Raw draft input value. Numeric values render as currency in the editable field. */
88
+ amountValue?: string | number | null
89
+ accountName: string
90
+ description?: string | null
91
+ churnType?: string | null
92
+ churnTypeOptions?: Array<string | OpportunityPreviewOption>
93
+ }
94
+
95
+ interface OpportunityDraft {
96
+ closeDate: string
97
+ amount: string
98
+ description: string
99
+ churnType: string
100
+ }
101
+
76
102
  interface SignalApprovalLabels {
77
103
  approveButton?: string
78
104
  dismissButton?: string
@@ -106,9 +132,9 @@ interface SignalApprovalContextValue {
106
132
  labels: Required<SignalApprovalLabels>
107
133
  hideApproveButton?: boolean
108
134
  approveButtonIconUrl?: string
109
- opportunityPreview?: RootProps['opportunityPreview']
135
+ opportunityPreview?: OpportunityPreview
110
136
  requestingApproval: boolean
111
- approve: () => void
137
+ approve: (draft?: OpportunityDraft) => void
112
138
  submitApproveFeedback: (reasons: string[], detail: string) => void
113
139
  skipApproveFeedback: () => void
114
140
  dismiss: (reasons: string[], detail: string, subReason?: string) => void
@@ -137,13 +163,7 @@ interface RootProps {
137
163
  /** Optional icon URL for the approve button. Renders an img instead of CirclePlus when provided. */
138
164
  approveButtonIconUrl?: string
139
165
  /** Optional structured preview data shown in the confirmation dialog. */
140
- opportunityPreview?: {
141
- name: string
142
- stage: string
143
- closeDate: string
144
- amount: string
145
- accountName: string
146
- }
166
+ opportunityPreview?: OpportunityPreview
147
167
  /**
148
168
  * Async callback fired when the user clicks the approve button, BEFORE
149
169
  * transitioning to the "confirming" state. While the promise is pending,
@@ -160,7 +180,7 @@ interface RootProps {
160
180
  * "creating" loading state while the promise is pending. On `true` it
161
181
  * transitions to the feedback step; on `false` it reverts to "pending".
162
182
  */
163
- onApprove?: () => void | Promise<boolean>
183
+ onApprove?: (draft?: OpportunityDraft) => void | Promise<boolean>
164
184
  onApproveFeedback?: (reasons: string[], detail: string) => void
165
185
  onDismiss?: (reasons: string[], detail: string, subReason?: string) => void
166
186
  }
@@ -205,8 +225,8 @@ function Root({ children, companyName, opportunityUrl, scheduledTime, initialApp
205
225
  setApprovalState("pending")
206
226
  }, [])
207
227
 
208
- const approve = React.useCallback(() => {
209
- const result = onApprove?.()
228
+ const approve = React.useCallback((draft?: OpportunityDraft) => {
229
+ const result = onApprove?.(draft)
210
230
  // If the callback returns a Promise, show a loading state and wait for it.
211
231
  if (result && typeof (result as Promise<boolean>).then === "function") {
212
232
  setApprovalState("creating")
@@ -417,6 +437,43 @@ function SubmittedFeedback({
417
437
  )
418
438
  }
419
439
 
440
+ function optionValue(option: string | OpportunityPreviewOption): string {
441
+ return typeof option === "string" ? option : option.value
442
+ }
443
+
444
+ function optionLabel(option: string | OpportunityPreviewOption): string {
445
+ return typeof option === "string" ? option : option.label
446
+ }
447
+
448
+ function formatAmountDraftValue(value: string | number | null | undefined): string {
449
+ if (value == null || value === "") return ""
450
+ if (typeof value === "number") {
451
+ return new Intl.NumberFormat("en-US", {
452
+ style: "currency",
453
+ currency: "USD",
454
+ maximumFractionDigits: 0,
455
+ }).format(value)
456
+ }
457
+ return value
458
+ }
459
+
460
+ function buildOpportunityDraft(preview?: OpportunityPreview): OpportunityDraft {
461
+ return {
462
+ closeDate: preview?.closeDateValue ?? preview?.closeDate ?? "",
463
+ amount: preview?.amountValue == null ? "" : formatAmountDraftValue(preview.amountValue),
464
+ description: preview?.description ?? "",
465
+ churnType: preview?.churnType ?? "",
466
+ }
467
+ }
468
+
469
+ function isValidDateInput(value: string): boolean {
470
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value)
471
+ if (!match) return false
472
+ const parsed = new Date(`${value}T00:00:00Z`)
473
+ if (Number.isNaN(parsed.getTime())) return false
474
+ return parsed.toISOString().slice(0, 10) === value
475
+ }
476
+
420
477
  function Actions() {
421
478
  const { approvalState, companyName, opportunityUrl, scheduledTime, labels, hideApproveButton, approveButtonIconUrl, opportunityPreview, requestingApproval, approve, submitApproveFeedback, skipApproveFeedback, dismiss, requestApproval, requestDismiss, cancel } =
422
479
  useSignalApproval()
@@ -426,6 +483,16 @@ function Actions() {
426
483
  const [detailText, setDetailText] = React.useState("")
427
484
  const [submittedFeedback, setSubmittedFeedback] = React.useState<{ reasons: string[]; detail: string; subReason?: string } | null>(null)
428
485
  const [isEditing, setIsEditing] = React.useState(false)
486
+ const [opportunityDraft, setOpportunityDraft] = React.useState<OpportunityDraft>(() => buildOpportunityDraft(opportunityPreview))
487
+
488
+ React.useEffect(() => {
489
+ if (approvalState === "confirming") {
490
+ setOpportunityDraft(buildOpportunityDraft(opportunityPreview))
491
+ }
492
+ }, [approvalState, opportunityPreview])
493
+
494
+ const churnTypeOptions = opportunityPreview?.churnTypeOptions ?? []
495
+ const hasChurnTypeOptions = churnTypeOptions.length > 0
429
496
 
430
497
  const topNode = dismissReasonTree.find((n) => n.label === selectedTopReason)
431
498
  const hasSubOptions = !!(topNode?.subOptions && topNode.subOptions.length > 0)
@@ -438,6 +505,7 @@ function Actions() {
438
505
  (!needsText || detailText.trim().length > 0)
439
506
 
440
507
  const canSubmitApprove = selectedReasons.length > 0 || detailText.trim().length > 0
508
+ const canConfirmOpportunity = !opportunityPreview || isValidDateInput(opportunityDraft.closeDate)
441
509
 
442
510
  const selectTopReason = (label: string) => {
443
511
  if (selectedTopReason === label) {
@@ -740,27 +808,91 @@ function Actions() {
740
808
  {labels.confirmPrompt} <strong>{companyName}</strong>. Confirm?
741
809
  </p>
742
810
  {opportunityPreview && (
743
- <div className="mt-3 space-y-1.5 border-t border-border/50 pt-3">
811
+ <div className="mt-3 space-y-3 border-t border-border/50 pt-3">
744
812
  {[
745
813
  { label: "Opportunity", value: opportunityPreview.name },
746
814
  { label: "Account", value: opportunityPreview.accountName },
747
815
  { label: "Stage", value: opportunityPreview.stage },
748
- { label: "Close Date", value: opportunityPreview.closeDate },
749
- { label: "Amount", value: opportunityPreview.amount },
750
816
  ].map(({ label, value }) => (
751
- <div key={label} className="flex items-center justify-between text-xs">
817
+ <div key={label} className="flex items-center justify-between gap-3 text-xs">
752
818
  <span className="text-muted-foreground">{label}</span>
753
- <span className="font-medium text-foreground">{value}</span>
819
+ <span className="text-right font-medium text-foreground">{value}</span>
754
820
  </div>
755
821
  ))}
822
+
823
+ <div className="grid gap-2 sm:grid-cols-2">
824
+ <label className="space-y-1 text-xs">
825
+ <span className="font-medium text-muted-foreground">Close Date</span>
826
+ <input
827
+ type="date"
828
+ value={opportunityDraft.closeDate}
829
+ onChange={(event) => setOpportunityDraft((draft) => ({ ...draft, closeDate: event.target.value }))}
830
+ aria-invalid={!canConfirmOpportunity}
831
+ className="h-8 w-full rounded-md border border-border bg-background px-2 text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-ring"
832
+ />
833
+ {!canConfirmOpportunity && (
834
+ <span className="text-[11px] text-red-600">Enter a valid close date.</span>
835
+ )}
836
+ </label>
837
+
838
+ <label className="space-y-1 text-xs">
839
+ <span className="font-medium text-muted-foreground">Amount</span>
840
+ <input
841
+ type="text"
842
+ inputMode="decimal"
843
+ value={opportunityDraft.amount}
844
+ onChange={(event) => setOpportunityDraft((draft) => ({ ...draft, amount: event.target.value }))}
845
+ placeholder={opportunityPreview.amount}
846
+ className="h-8 w-full rounded-md border border-border bg-background px-2 text-xs text-foreground placeholder:text-muted-foreground/60 focus:outline-none focus:ring-1 focus:ring-ring"
847
+ />
848
+ </label>
849
+ </div>
850
+
851
+ <label className="space-y-1 text-xs">
852
+ <span className="font-medium text-muted-foreground">Churn Type</span>
853
+ {hasChurnTypeOptions ? (
854
+ <select
855
+ value={opportunityDraft.churnType}
856
+ onChange={(event) => setOpportunityDraft((draft) => ({ ...draft, churnType: event.target.value }))}
857
+ className="h-8 w-full rounded-md border border-border bg-background px-2 text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-ring"
858
+ >
859
+ <option value="">No churn type</option>
860
+ {churnTypeOptions.map((option) => (
861
+ <option key={optionValue(option)} value={optionValue(option)}>
862
+ {optionLabel(option)}
863
+ </option>
864
+ ))}
865
+ </select>
866
+ ) : (
867
+ <input
868
+ type="text"
869
+ value={opportunityDraft.churnType}
870
+ onChange={(event) => setOpportunityDraft((draft) => ({ ...draft, churnType: event.target.value }))}
871
+ placeholder="No churn type"
872
+ className="h-8 w-full rounded-md border border-border bg-background px-2 text-xs text-foreground placeholder:text-muted-foreground/60 focus:outline-none focus:ring-1 focus:ring-ring"
873
+ />
874
+ )}
875
+ </label>
876
+
877
+ <label className="space-y-1 text-xs">
878
+ <span className="font-medium text-muted-foreground">Description</span>
879
+ <textarea
880
+ value={opportunityDraft.description}
881
+ onChange={(event) => setOpportunityDraft((draft) => ({ ...draft, description: event.target.value }))}
882
+ rows={3}
883
+ placeholder="Add a short description"
884
+ className="w-full resize-none rounded-md border border-border bg-background px-2 py-1.5 text-xs text-foreground placeholder:text-muted-foreground/60 focus:outline-none focus:ring-1 focus:ring-ring"
885
+ />
886
+ </label>
756
887
  </div>
757
888
  )}
758
889
  </div>
759
890
  <div className="flex items-center gap-2">
760
891
  <button
761
892
  type="button"
762
- onClick={approve}
763
- className="inline-flex h-7 items-center gap-1.5 rounded-md bg-foreground px-3 text-xs font-semibold text-background transition-colors hover:bg-foreground/90"
893
+ onClick={() => approve(opportunityDraft)}
894
+ disabled={!canConfirmOpportunity}
895
+ className="inline-flex h-7 items-center gap-1.5 rounded-md bg-foreground px-3 text-xs font-semibold text-background transition-colors hover:bg-foreground/90 disabled:cursor-not-allowed disabled:bg-muted disabled:text-muted-foreground"
764
896
  >
765
897
  <Check className="h-3 w-3" />
766
898
  Confirm
@@ -861,5 +993,4 @@ export {
861
993
  Gate as SignalApprovalGate,
862
994
  }
863
995
  export const SignalApproval = { Root, Actions, Gate }
864
- export type OpportunityPreview = NonNullable<RootProps['opportunityPreview']>
865
- export type { ApprovalState, SignalApprovalLabels, SignalApprovalContextValue, RootProps as SignalApprovalRootProps }
996
+ export type { ApprovalState, OpportunityPreview, OpportunityDraft, SignalApprovalLabels, SignalApprovalContextValue, RootProps as SignalApprovalRootProps }
@@ -180,7 +180,7 @@ export interface InboxViewConfig {
180
180
  quickFilterTabs?: Array<{ id: string; label: string; matchValue?: string; count?: number }>
181
181
  hideAccountsButton?: boolean
182
182
  accountDetailsLabel?: string
183
- onSignalApprove?: (item: QueueItem) => void | Promise<boolean>
183
+ onSignalApprove?: (item: QueueItem, draft?: import("../components/signal-feedback-inline").OpportunityDraft) => void | Promise<boolean>
184
184
  getSignalApprovalState?: (item: QueueItem) => ApprovalState | undefined
185
185
  signalLabels?: {
186
186
  approveButton?: string
@@ -38,7 +38,7 @@ import {
38
38
  type InboxFilterCategory,
39
39
  } from "../components/inbox-toolbar"
40
40
  import { GroupedListView, type GroupedListGroup } from "../components/item-list"
41
- import { SignalApproval, type ApprovalState, type OpportunityPreview } from "../components/signal-feedback-inline"
41
+ import { SignalApproval, type ApprovalState, type OpportunityDraft, type OpportunityPreview } from "../components/signal-feedback-inline"
42
42
  import { ScoreWhyChips } from "../components/score-why-chips"
43
43
  import { SignalPriorityPopover } from "../components/signal-priority-popover"
44
44
  import { type SourceDef } from "../components/detail-view"
@@ -149,7 +149,7 @@ export interface DetailViewProps {
149
149
  onSuggestedActionFeedback?: (actionId: number | string, feedback: string, actionTitle?: string) => void
150
150
  /** @deprecated The compact score UX no longer renders score-level thumbs by default. */
151
151
  onScoreFeedback?: (type: "up" | "down", pills: string[], detail: string) => void
152
- onSignalApprove?: (item: QueueItem) => void | Promise<boolean>
152
+ onSignalApprove?: (item: QueueItem, draft?: OpportunityDraft) => void | Promise<boolean>
153
153
  getSignalApprovalState?: (item: QueueItem) => ApprovalState | undefined
154
154
  signalLabels?: InboxViewConfig["signalLabels"]
155
155
  hideApproveButton?: boolean
@@ -475,7 +475,7 @@ export function DetailView({
475
475
  opportunityPreview={opportunityPreview}
476
476
  onRequestApproval={onRequestApproval}
477
477
  initialApprovalState={getSignalApprovalState?.(item)}
478
- onApprove={() => onSignalApprove?.(item)}
478
+ onApprove={(draft) => onSignalApprove?.(item, draft)}
479
479
  onApproveFeedback={(reasons, detail) => {
480
480
  signalData.onApproveFeedback?.(reasons, detail)
481
481
  console.log("Approval feedback:", { taskId: item.id, company: item.company, reasons, detail })