@handled-ai/design-system 0.18.50 → 0.18.52
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/button.d.ts +1 -1
- package/dist/components/data-table-filter.d.ts +21 -6
- package/dist/components/data-table-filter.js +134 -9
- package/dist/components/data-table-filter.js.map +1 -1
- package/dist/components/pill.d.ts +1 -1
- package/dist/components/score-why-chips.d.ts +1 -1
- package/dist/components/signal-feedback-inline.d.ts +28 -12
- package/dist/components/signal-feedback-inline.js +146 -10
- package/dist/components/signal-feedback-inline.js.map +1 -1
- package/dist/components/signal-priority-popover.d.ts +1 -1
- package/dist/components/signal-priority-popover.js +7 -15
- package/dist/components/signal-priority-popover.js.map +1 -1
- package/dist/components/tabs.d.ts +1 -1
- package/dist/index.d.ts +3 -3
- 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 +3 -3
- package/dist/prototype/prototype-inbox-view.js +1 -2
- 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-Cl98xw1n.d.ts → signal-priority-popover-QJngMAj7.d.ts} +4 -9
- package/package.json +1 -1
- package/src/components/__tests__/case-panel-why.test.tsx +126 -0
- package/src/components/__tests__/data-table-filter.test.tsx +130 -0
- package/src/components/__tests__/signal-priority-popover.test.tsx +4 -27
- package/src/components/data-table-filter.tsx +160 -9
- package/src/components/signal-feedback-inline.tsx +181 -20
- package/src/components/signal-priority-popover.tsx +6 -16
- package/src/prototype/__tests__/detail-view-opportunity-preview.test.tsx +90 -0
- package/src/prototype/__tests__/detail-view-score-why.test.tsx +0 -32
- package/src/prototype/prototype-config.ts +3 -5
- package/src/prototype/prototype-inbox-view.tsx +3 -4
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
|
-
import { h as InsightsViewConfig } from '../signal-priority-popover-
|
|
2
|
+
import { h as InsightsViewConfig } from '../signal-priority-popover-QJngMAj7.js';
|
|
3
3
|
import '../components/feedback-primitives.js';
|
|
4
4
|
import '../components/quick-action-sidebar-nav.js';
|
|
5
5
|
import '../components/quick-action-modal.js';
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
|
-
import { j as PrototypeConfig } from '../signal-priority-popover-
|
|
2
|
+
import { j as PrototypeConfig } from '../signal-priority-popover-QJngMAj7.js';
|
|
3
3
|
import '../components/feedback-primitives.js';
|
|
4
4
|
import '../components/quick-action-sidebar-nav.js';
|
|
5
5
|
import '../components/quick-action-modal.js';
|
package/dist/{signal-priority-popover-Cl98xw1n.d.ts → signal-priority-popover-QJngMAj7.d.ts}
RENAMED
|
@@ -10,7 +10,7 @@ import { DataRow } from './components/data-table.js';
|
|
|
10
10
|
import { MetricCardProps } from './components/metric-card.js';
|
|
11
11
|
import { PipelineStage, PipelineStageMetrics, PipelineStageTiming } from './charts/pipeline-overview.js';
|
|
12
12
|
import { TimelineEvent } from './components/timeline-activity.js';
|
|
13
|
-
import { ApprovalState } from './components/signal-feedback-inline.js';
|
|
13
|
+
import { OpportunityDraft, ApprovalState } from './components/signal-feedback-inline.js';
|
|
14
14
|
import { LucideIcon } from 'lucide-react';
|
|
15
15
|
|
|
16
16
|
interface TimelineSystemEventsConfig {
|
|
@@ -93,8 +93,6 @@ interface SignalScoreData {
|
|
|
93
93
|
confidence: number;
|
|
94
94
|
urgencyLabel?: SignalScoreUrgencyLabel;
|
|
95
95
|
urgencyExplanation?: string;
|
|
96
|
-
/** Controls whether the priority popover header shows the raw overall score number. @default "number" */
|
|
97
|
-
priorityScoreDisplay?: SignalPriorityScoreDisplay;
|
|
98
96
|
explanationBuckets?: SignalScoreExplanationBucket[];
|
|
99
97
|
onFactorFeedback?: (factorKey: string, type: "up" | "down" | null, detail?: string) => void;
|
|
100
98
|
/** @deprecated The compact score UX no longer renders score-level thumbs by default. */
|
|
@@ -183,7 +181,7 @@ interface InboxViewConfig {
|
|
|
183
181
|
}>;
|
|
184
182
|
hideAccountsButton?: boolean;
|
|
185
183
|
accountDetailsLabel?: string;
|
|
186
|
-
onSignalApprove?: (item: QueueItem) => void | Promise<boolean>;
|
|
184
|
+
onSignalApprove?: (item: QueueItem, draft?: OpportunityDraft) => void | Promise<boolean>;
|
|
187
185
|
getSignalApprovalState?: (item: QueueItem) => ApprovalState | undefined;
|
|
188
186
|
signalLabels?: {
|
|
189
187
|
approveButton?: string;
|
|
@@ -414,11 +412,8 @@ interface PriorityFactor {
|
|
|
414
412
|
/** Evidence text (e.g. "$3.4M moved in 8h - current treasury balance $0.00"). */
|
|
415
413
|
rationale: string;
|
|
416
414
|
}
|
|
417
|
-
type SignalPriorityScoreDisplay = "label" | "number";
|
|
418
415
|
interface SignalPriorityPopoverProps {
|
|
419
416
|
score: number;
|
|
420
|
-
/** Controls whether the overall score number is shown in the popover header. @default "number" */
|
|
421
|
-
scoreDisplay?: SignalPriorityScoreDisplay;
|
|
422
417
|
urgencyLabel?: SignalScoreUrgencyLabel;
|
|
423
418
|
/** Synthesis sentence displayed in the popover head. */
|
|
424
419
|
urgencyExplanation?: string;
|
|
@@ -440,6 +435,6 @@ interface SignalPriorityPopoverProps {
|
|
|
440
435
|
/** Persisted priority-level feedback for the footer. */
|
|
441
436
|
initialPriorityFeedback?: PersistedFeedbackData | null;
|
|
442
437
|
}
|
|
443
|
-
declare function SignalPriorityPopover({ score, urgencyLabel: providedLabel, urgencyExplanation, factors, metaText, feedbackChips, onFeedbackSubmit, className, initialFactorFeedback, onFactorFeedback, initialPriorityFeedback,
|
|
438
|
+
declare function SignalPriorityPopover({ score, urgencyLabel: providedLabel, urgencyExplanation, factors, metaText, feedbackChips, onFeedbackSubmit, className, initialFactorFeedback, onFactorFeedback, initialPriorityFeedback, }: SignalPriorityPopoverProps): React.JSX.Element;
|
|
444
439
|
|
|
445
|
-
export { type AccountFilterTab as A, type BriefStyleVariant as B, type EntityPanelConfig as E, type InboxDetailSections as I, type PriorityFactor as P, type QueueItem as Q, SignalPriorityPopover as S, type TimelineSystemEventsConfig as T, type WorkQueueViewConfig as W, type AccountsViewConfig as a, type AdminTab as b, type AdminViewConfig as c, type EntityPanelSection as d, type InboxSortOption as e, type InboxViewConfig as f, type InsightsCustomTab as g, type InsightsViewConfig as h, type PrototypeBrandConfig as i, type PrototypeConfig as j, type SignalPriorityPopoverProps as k, type SignalScoreData as l, type SignalScoreExplanationBucket as m, type SignalScoreExplanationSignal as n, type SignalScoreUrgencyLabel as o
|
|
440
|
+
export { type AccountFilterTab as A, type BriefStyleVariant as B, type EntityPanelConfig as E, type InboxDetailSections as I, type PriorityFactor as P, type QueueItem as Q, SignalPriorityPopover as S, type TimelineSystemEventsConfig as T, type WorkQueueViewConfig as W, type AccountsViewConfig as a, type AdminTab as b, type AdminViewConfig as c, type EntityPanelSection as d, type InboxSortOption as e, type InboxViewConfig as f, type InsightsCustomTab as g, type InsightsViewConfig as h, type PrototypeBrandConfig as i, type PrototypeConfig as j, type SignalPriorityPopoverProps as k, type SignalScoreData as l, type SignalScoreExplanationBucket as m, type SignalScoreExplanationSignal as n, type SignalScoreUrgencyLabel as o };
|
package/package.json
CHANGED
|
@@ -149,4 +149,130 @@ 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("keeps legacy display-only opportunity previews confirmable", () => {
|
|
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
|
+
amount: "$75,000",
|
|
200
|
+
},
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
expect(screen.getByText(/this will approve this action for/i)).toBeInTheDocument()
|
|
204
|
+
expect(screen.queryByLabelText("Close Date")).not.toBeInTheDocument()
|
|
205
|
+
expect(screen.getByRole("button", { name: /confirm/i })).not.toBeDisabled()
|
|
206
|
+
|
|
207
|
+
fireEvent.click(screen.getByRole("button", { name: /confirm/i }))
|
|
208
|
+
expect(onApprove).toHaveBeenCalledWith(undefined)
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it("transitions to editable preview fields after async request approval in StrictMode", async () => {
|
|
212
|
+
function ApprovalPreviewHarness() {
|
|
213
|
+
const [opportunityPreview, setOpportunityPreview] = React.useState<React.ComponentProps<typeof SignalApproval.Root>["opportunityPreview"]>()
|
|
214
|
+
|
|
215
|
+
return (
|
|
216
|
+
<SignalApproval.Root
|
|
217
|
+
companyName="Northwind Systems"
|
|
218
|
+
labels={{ approveButton: "Create in Salesforce", dismissButton: "Not Helpful" }}
|
|
219
|
+
opportunityPreview={opportunityPreview}
|
|
220
|
+
onRequestApproval={async () => {
|
|
221
|
+
setOpportunityPreview({
|
|
222
|
+
name: "Churn Risk - Northwind Systems",
|
|
223
|
+
accountName: "Northwind Systems",
|
|
224
|
+
stage: "Prospecting",
|
|
225
|
+
closeDate: "Jun 30, 2026",
|
|
226
|
+
closeDateValue: "2026-06-30",
|
|
227
|
+
amount: "$75,000",
|
|
228
|
+
amountValue: 75000,
|
|
229
|
+
description: "Initial description",
|
|
230
|
+
churnType: "Churn Risk",
|
|
231
|
+
churnTypeOptions: ["Churn Risk", "Win Back"],
|
|
232
|
+
})
|
|
233
|
+
}}
|
|
234
|
+
>
|
|
235
|
+
<CasePanelSignalApprovalActions />
|
|
236
|
+
</SignalApproval.Root>
|
|
237
|
+
)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
render(
|
|
241
|
+
<React.StrictMode>
|
|
242
|
+
<ApprovalPreviewHarness />
|
|
243
|
+
</React.StrictMode>,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
fireEvent.click(screen.getByRole("button", { name: /create in salesforce/i }))
|
|
247
|
+
|
|
248
|
+
expect(await screen.findByLabelText("Close Date")).toHaveValue("2026-06-30")
|
|
249
|
+
expect(screen.getByLabelText("Amount")).toHaveValue("$75,000")
|
|
250
|
+
expect(screen.getByLabelText("Churn Type")).toHaveValue("Churn Risk")
|
|
251
|
+
expect(screen.getByLabelText("Description")).toHaveValue("Initial description")
|
|
252
|
+
expect(screen.getByRole("button", { name: /confirm/i })).not.toBeDisabled()
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it("blocks confirmation until the close date is valid", () => {
|
|
256
|
+
const onApprove = vi.fn()
|
|
257
|
+
|
|
258
|
+
renderApprovalActions({
|
|
259
|
+
initialApprovalState: "confirming",
|
|
260
|
+
onApprove,
|
|
261
|
+
opportunityPreview: {
|
|
262
|
+
name: "Churn Risk - Northwind Systems",
|
|
263
|
+
accountName: "Northwind Systems",
|
|
264
|
+
stage: "Prospecting",
|
|
265
|
+
closeDate: "Jun 30, 2026",
|
|
266
|
+
closeDateValue: "2026-06-30",
|
|
267
|
+
amount: "$75,000",
|
|
268
|
+
},
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
fireEvent.change(screen.getByLabelText("Close Date"), { target: { value: "" } })
|
|
272
|
+
expect(screen.getByRole("button", { name: /confirm/i })).toBeDisabled()
|
|
273
|
+
expect(screen.getByText("Enter a valid close date.")).toBeInTheDocument()
|
|
274
|
+
|
|
275
|
+
fireEvent.click(screen.getByRole("button", { name: /confirm/i }))
|
|
276
|
+
expect(onApprove).not.toHaveBeenCalled()
|
|
277
|
+
})
|
|
152
278
|
})
|
|
@@ -264,6 +264,136 @@ describe("DataTableFilter", () => {
|
|
|
264
264
|
expect(openItem!.querySelector("span.rounded-full")).toBeNull();
|
|
265
265
|
});
|
|
266
266
|
|
|
267
|
+
|
|
268
|
+
it("renders a text category as a submenu with a text input", () => {
|
|
269
|
+
const textCategory: DataTableFilterCategory = {
|
|
270
|
+
id: "callsign",
|
|
271
|
+
label: "Callsign",
|
|
272
|
+
icon: ListFilter,
|
|
273
|
+
type: "text",
|
|
274
|
+
valuePlaceholder: "Enter callsign",
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
render(
|
|
278
|
+
<DataTableFilter
|
|
279
|
+
categories={[textCategory]}
|
|
280
|
+
selectedFilters={{}}
|
|
281
|
+
onToggleFilter={() => {}}
|
|
282
|
+
/>
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
const subTrigger = document.querySelector('[data-slot="dropdown-menu-sub-trigger"]');
|
|
286
|
+
expect(subTrigger).not.toBeNull();
|
|
287
|
+
expect(subTrigger!.textContent).toContain("Callsign");
|
|
288
|
+
expect(screen.getByLabelText("Callsign")).toBeDefined();
|
|
289
|
+
expect(screen.getByPlaceholderText("Enter callsign")).toBeDefined();
|
|
290
|
+
expect(screen.getByRole("button", { name: "Apply" })).toBeDefined();
|
|
291
|
+
expect(screen.queryByText("No matches")).toBeNull();
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("applies a trimmed text filter value", () => {
|
|
295
|
+
const onTextFilterChange = vi.fn();
|
|
296
|
+
const textCategory: DataTableFilterCategory = {
|
|
297
|
+
id: "callsign",
|
|
298
|
+
label: "Callsign",
|
|
299
|
+
icon: ListFilter,
|
|
300
|
+
type: "text",
|
|
301
|
+
valuePlaceholder: "Enter callsign",
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
render(
|
|
305
|
+
<DataTableFilter
|
|
306
|
+
categories={[textCategory]}
|
|
307
|
+
selectedFilters={{}}
|
|
308
|
+
onToggleFilter={() => {}}
|
|
309
|
+
textFilters={{}}
|
|
310
|
+
onTextFilterChange={onTextFilterChange}
|
|
311
|
+
/>
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
fireEvent.change(screen.getByLabelText("Callsign"), {
|
|
315
|
+
target: { value: " M42 " },
|
|
316
|
+
});
|
|
317
|
+
fireEvent.click(screen.getByRole("button", { name: "Apply" }));
|
|
318
|
+
|
|
319
|
+
expect(onTextFilterChange).toHaveBeenCalledWith("callsign", "M42");
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it("clears an active text filter value", () => {
|
|
323
|
+
const onTextFilterChange = vi.fn();
|
|
324
|
+
const textCategory: DataTableFilterCategory = {
|
|
325
|
+
id: "organizationId",
|
|
326
|
+
label: "Organization ID",
|
|
327
|
+
icon: ListFilter,
|
|
328
|
+
type: "text",
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
render(
|
|
332
|
+
<DataTableFilter
|
|
333
|
+
categories={[textCategory]}
|
|
334
|
+
selectedFilters={{}}
|
|
335
|
+
onToggleFilter={() => {}}
|
|
336
|
+
textFilters={{ organizationId: "ORG-123" }}
|
|
337
|
+
onTextFilterChange={onTextFilterChange}
|
|
338
|
+
/>
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
expect(screen.getByLabelText("Organization ID")).toHaveProperty("value", "ORG-123");
|
|
342
|
+
fireEvent.click(screen.getByRole("button", { name: "Clear" }));
|
|
343
|
+
|
|
344
|
+
expect(onTextFilterChange).toHaveBeenCalledWith("organizationId", "");
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("includes active text filters in the filter count", () => {
|
|
348
|
+
const textCategory: DataTableFilterCategory = {
|
|
349
|
+
id: "callsign",
|
|
350
|
+
label: "Callsign",
|
|
351
|
+
icon: ListFilter,
|
|
352
|
+
type: "text",
|
|
353
|
+
};
|
|
354
|
+
const optionCategory: DataTableFilterCategory = {
|
|
355
|
+
id: "status",
|
|
356
|
+
label: "Status",
|
|
357
|
+
icon: ListFilter,
|
|
358
|
+
options: ["Open", "Closed"],
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
render(
|
|
362
|
+
<DataTableFilter
|
|
363
|
+
categories={[textCategory, optionCategory]}
|
|
364
|
+
selectedFilters={{ status: ["Open"] }}
|
|
365
|
+
onToggleFilter={() => {}}
|
|
366
|
+
textFilters={{ callsign: " M42 " }}
|
|
367
|
+
/>
|
|
368
|
+
);
|
|
369
|
+
|
|
370
|
+
const badge = screen.getByText("2");
|
|
371
|
+
expect(badge.tagName).toBe("SPAN");
|
|
372
|
+
expect(badge.className).toContain("bg-muted");
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it("does not count blank text filter values as active", () => {
|
|
376
|
+
const textCategory: DataTableFilterCategory = {
|
|
377
|
+
id: "callsign",
|
|
378
|
+
label: "Callsign",
|
|
379
|
+
icon: ListFilter,
|
|
380
|
+
type: "text",
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
render(
|
|
384
|
+
<DataTableFilter
|
|
385
|
+
categories={[textCategory]}
|
|
386
|
+
selectedFilters={{}}
|
|
387
|
+
onToggleFilter={() => {}}
|
|
388
|
+
textFilters={{ callsign: " " }}
|
|
389
|
+
/>
|
|
390
|
+
);
|
|
391
|
+
|
|
392
|
+
const triggerButton = document.querySelector('[data-slot="dropdown-menu-trigger"]');
|
|
393
|
+
expect(triggerButton).not.toBeNull();
|
|
394
|
+
expect(triggerButton!.querySelector("span.bg-muted")).toBeNull();
|
|
395
|
+
});
|
|
396
|
+
|
|
267
397
|
it("does not expose the condition builder entry point without condition fields", () => {
|
|
268
398
|
render(<DataTableFilter {...defaultProps} />);
|
|
269
399
|
|
|
@@ -119,8 +119,8 @@ describe("SignalPriorityPopover", () => {
|
|
|
119
119
|
|
|
120
120
|
// Check head section
|
|
121
121
|
expect(content.textContent).toContain("Why this is high priority")
|
|
122
|
-
expect(
|
|
123
|
-
expect(
|
|
122
|
+
expect(content.textContent).toContain("79")
|
|
123
|
+
expect(content.textContent).toContain("/100")
|
|
124
124
|
expect(content.textContent).toContain("High range")
|
|
125
125
|
expect(content.textContent).toContain("60-79")
|
|
126
126
|
})
|
|
@@ -188,36 +188,13 @@ describe("SignalPriorityPopover", () => {
|
|
|
188
188
|
expect(row.textContent).not.toContain("Raises0/100")
|
|
189
189
|
})
|
|
190
190
|
|
|
191
|
-
it("renders Contributing factors section label
|
|
191
|
+
it("renders Contributing factors section label", () => {
|
|
192
192
|
render(<SignalPriorityPopover {...defaultProps} />)
|
|
193
193
|
fireEvent.click(screen.getByTestId("priority-popover-trigger"))
|
|
194
194
|
|
|
195
195
|
const content = screen.getByTestId("priority-popover-content")
|
|
196
196
|
expect(content.textContent).toContain("Contributing factors")
|
|
197
|
-
expect(content.textContent).toContain("
|
|
198
|
-
expect(content.textContent).not.toContain("Score = weighted sum")
|
|
199
|
-
})
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
it("renders the overall score number by default while preserving factor row scores", () => {
|
|
203
|
-
render(<SignalPriorityPopover {...defaultProps} />)
|
|
204
|
-
fireEvent.click(screen.getByTestId("priority-popover-trigger"))
|
|
205
|
-
|
|
206
|
-
expect(screen.getByTestId("priority-overall-score").textContent).toBe("79/100")
|
|
207
|
-
expect(screen.getByTestId("factor-row-test_severity").textContent).toContain("85/100")
|
|
208
|
-
expect(screen.getByTestId("factor-row-account_depth").textContent).toContain("30/100")
|
|
209
|
-
})
|
|
210
|
-
|
|
211
|
-
it("hides only the overall header score in label display mode", () => {
|
|
212
|
-
render(<SignalPriorityPopover {...defaultProps} scoreDisplay="label" />)
|
|
213
|
-
fireEvent.click(screen.getByTestId("priority-popover-trigger"))
|
|
214
|
-
|
|
215
|
-
const header = screen.getByTestId("priority-popover-header")
|
|
216
|
-
expect(screen.queryByTestId("priority-overall-score")).toBeNull()
|
|
217
|
-
expect(header.textContent).toContain("Why this is high priority")
|
|
218
|
-
expect(header.textContent).not.toContain("79/100")
|
|
219
|
-
expect(screen.getByTestId("factor-row-test_severity").textContent).toContain("85/100")
|
|
220
|
-
expect(screen.getByTestId("factor-row-account_depth").textContent).toContain("30/100")
|
|
197
|
+
expect(content.textContent).toContain("Score = weighted sum")
|
|
221
198
|
})
|
|
222
199
|
|
|
223
200
|
it("renders score track bars with correct width percentage", () => {
|
|
@@ -28,13 +28,10 @@ export interface FilterOption {
|
|
|
28
28
|
value: string
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
interface DataTableFilterCategoryBase {
|
|
32
32
|
id: string
|
|
33
33
|
label: string
|
|
34
34
|
icon: React.ComponentType<{ className?: string }>
|
|
35
|
-
options: (string | FilterOption)[]
|
|
36
|
-
/** Filter behavior. Defaults to "multi" (checkbox multi-select). */
|
|
37
|
-
type?: "multi" | "single" | "boolean"
|
|
38
35
|
/**
|
|
39
36
|
* Submenu search behavior. Defaults to the DataTableFilter
|
|
40
37
|
* optionSearchThreshold prop. Use true to always show search or false to
|
|
@@ -43,6 +40,25 @@ export interface DataTableFilterCategory {
|
|
|
43
40
|
searchable?: boolean | { threshold?: number }
|
|
44
41
|
}
|
|
45
42
|
|
|
43
|
+
export interface DataTableOptionFilterCategory extends DataTableFilterCategoryBase {
|
|
44
|
+
options: (string | FilterOption)[]
|
|
45
|
+
/** Filter behavior. Defaults to "multi" (checkbox multi-select). */
|
|
46
|
+
type?: "multi" | "single" | "boolean"
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface DataTableTextFilterCategory extends DataTableFilterCategoryBase {
|
|
50
|
+
/** Free-text filter behavior. Renders a top-level submenu with a text input. */
|
|
51
|
+
type: "text"
|
|
52
|
+
/** Placeholder shown in the text filter input. */
|
|
53
|
+
valuePlaceholder?: string
|
|
54
|
+
/** Not used for text filters; optional for backwards-compatible category shapes. */
|
|
55
|
+
options?: (string | FilterOption)[]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type DataTableFilterCategory =
|
|
59
|
+
| DataTableOptionFilterCategory
|
|
60
|
+
| DataTableTextFilterCategory
|
|
61
|
+
|
|
46
62
|
function getOptionValue(option: string | FilterOption): string {
|
|
47
63
|
return typeof option === "string" ? option : option.value
|
|
48
64
|
}
|
|
@@ -50,6 +66,111 @@ function getOptionLabel(option: string | FilterOption): string {
|
|
|
50
66
|
return typeof option === "string" ? option : option.label
|
|
51
67
|
}
|
|
52
68
|
|
|
69
|
+
function isTextFilterCategory(
|
|
70
|
+
category: DataTableFilterCategory
|
|
71
|
+
): category is DataTableTextFilterCategory {
|
|
72
|
+
return category.type === "text"
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function TextFilterSubmenu({
|
|
76
|
+
category,
|
|
77
|
+
value,
|
|
78
|
+
onValueChange,
|
|
79
|
+
}: {
|
|
80
|
+
category: DataTableTextFilterCategory
|
|
81
|
+
value: string
|
|
82
|
+
onValueChange?: (categoryId: string, value: string) => void
|
|
83
|
+
}) {
|
|
84
|
+
const [draftValue, setDraftValue] = React.useState(value)
|
|
85
|
+
|
|
86
|
+
React.useEffect(() => {
|
|
87
|
+
setDraftValue(value)
|
|
88
|
+
}, [value])
|
|
89
|
+
|
|
90
|
+
const active = value.trim().length > 0
|
|
91
|
+
const applyValue = React.useCallback(() => {
|
|
92
|
+
onValueChange?.(category.id, draftValue.trim())
|
|
93
|
+
}, [category.id, draftValue, onValueChange])
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<DropdownMenuSub
|
|
97
|
+
onOpenChange={(open) => {
|
|
98
|
+
if (!open) {
|
|
99
|
+
setDraftValue(value)
|
|
100
|
+
}
|
|
101
|
+
}}
|
|
102
|
+
>
|
|
103
|
+
<DropdownMenuSubTrigger
|
|
104
|
+
className={cn(
|
|
105
|
+
"cursor-pointer py-1.5 text-xs",
|
|
106
|
+
active && "text-brand-purple"
|
|
107
|
+
)}
|
|
108
|
+
>
|
|
109
|
+
<category.icon
|
|
110
|
+
className={cn(
|
|
111
|
+
"mr-2 h-3.5 w-3.5 text-muted-foreground",
|
|
112
|
+
active && "text-brand-purple"
|
|
113
|
+
)}
|
|
114
|
+
/>
|
|
115
|
+
{category.label}
|
|
116
|
+
{active ? <Check className="ml-auto h-4 w-4" /> : null}
|
|
117
|
+
</DropdownMenuSubTrigger>
|
|
118
|
+
<DropdownMenuSubContent className="w-64 p-2">
|
|
119
|
+
<div className="space-y-2">
|
|
120
|
+
<input
|
|
121
|
+
aria-label={category.label}
|
|
122
|
+
className="h-8 w-full rounded-md bg-muted/50 px-2 py-1 text-xs outline-none transition-colors placeholder:text-muted-foreground/70 focus:bg-muted"
|
|
123
|
+
placeholder={
|
|
124
|
+
category.valuePlaceholder ??
|
|
125
|
+
`Enter ${category.label.toLowerCase()}...`
|
|
126
|
+
}
|
|
127
|
+
value={draftValue}
|
|
128
|
+
onChange={(event) => setDraftValue(event.target.value)}
|
|
129
|
+
onClick={(event) => event.stopPropagation()}
|
|
130
|
+
onKeyDown={(event) => {
|
|
131
|
+
event.stopPropagation()
|
|
132
|
+
if (event.key === "Enter") {
|
|
133
|
+
event.preventDefault()
|
|
134
|
+
applyValue()
|
|
135
|
+
}
|
|
136
|
+
}}
|
|
137
|
+
/>
|
|
138
|
+
<div className="flex items-center justify-end gap-2">
|
|
139
|
+
{active ? (
|
|
140
|
+
<Button
|
|
141
|
+
type="button"
|
|
142
|
+
variant="ghost"
|
|
143
|
+
size="sm"
|
|
144
|
+
className="h-7 px-2 text-xs"
|
|
145
|
+
onClick={(event) => {
|
|
146
|
+
event.preventDefault()
|
|
147
|
+
event.stopPropagation()
|
|
148
|
+
setDraftValue("")
|
|
149
|
+
onValueChange?.(category.id, "")
|
|
150
|
+
}}
|
|
151
|
+
>
|
|
152
|
+
Clear
|
|
153
|
+
</Button>
|
|
154
|
+
) : null}
|
|
155
|
+
<Button
|
|
156
|
+
type="button"
|
|
157
|
+
size="sm"
|
|
158
|
+
className="h-7 px-2 text-xs"
|
|
159
|
+
onClick={(event) => {
|
|
160
|
+
event.preventDefault()
|
|
161
|
+
event.stopPropagation()
|
|
162
|
+
applyValue()
|
|
163
|
+
}}
|
|
164
|
+
>
|
|
165
|
+
Apply
|
|
166
|
+
</Button>
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
</DropdownMenuSubContent>
|
|
170
|
+
</DropdownMenuSub>
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
|
|
53
174
|
export interface DataTableFilterProps {
|
|
54
175
|
categories: DataTableFilterCategory[]
|
|
55
176
|
selectedFilters: Record<string, string[]>
|
|
@@ -71,6 +192,10 @@ export interface DataTableFilterProps {
|
|
|
71
192
|
onConditionFiltersChange?: (conditions: ConditionFilterValue[]) => void
|
|
72
193
|
/** Dropdown entry label for the condition-builder panel. Default: "Add filter". */
|
|
73
194
|
conditionBuilderLabel?: string
|
|
195
|
+
/** Active free-text filters keyed by category id. */
|
|
196
|
+
textFilters?: Record<string, string>
|
|
197
|
+
/** Callback when a free-text filter value is applied or cleared. */
|
|
198
|
+
onTextFilterChange?: (categoryId: string, value: string) => void
|
|
74
199
|
}
|
|
75
200
|
|
|
76
201
|
export function DataTableFilter({
|
|
@@ -86,6 +211,8 @@ export function DataTableFilter({
|
|
|
86
211
|
conditionFilters = [],
|
|
87
212
|
onConditionFiltersChange,
|
|
88
213
|
conditionBuilderLabel = "Add filter",
|
|
214
|
+
textFilters = {},
|
|
215
|
+
onTextFilterChange,
|
|
89
216
|
}: DataTableFilterProps) {
|
|
90
217
|
const [query, setQuery] = React.useState("")
|
|
91
218
|
const [subQueries, setSubQueries] = React.useState<Record<string, string>>({})
|
|
@@ -103,6 +230,10 @@ export function DataTableFilter({
|
|
|
103
230
|
return true
|
|
104
231
|
}
|
|
105
232
|
|
|
233
|
+
if (isTextFilterCategory(category)) {
|
|
234
|
+
return false
|
|
235
|
+
}
|
|
236
|
+
|
|
106
237
|
return category.options.some((option) =>
|
|
107
238
|
getOptionLabel(option).toLowerCase().includes(normalized)
|
|
108
239
|
)
|
|
@@ -123,8 +254,16 @@ export function DataTableFilter({
|
|
|
123
254
|
0
|
|
124
255
|
)
|
|
125
256
|
|
|
126
|
-
|
|
127
|
-
|
|
257
|
+
const textCount = categories.reduce((count, category) => {
|
|
258
|
+
if (!isTextFilterCategory(category)) {
|
|
259
|
+
return count
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return textFilters[category.id]?.trim() ? count + 1 : count
|
|
263
|
+
}, 0)
|
|
264
|
+
|
|
265
|
+
return userCount + conditionFilters.length + textCount
|
|
266
|
+
}, [categories, selectedFilters, conditionFilters.length, textFilters])
|
|
128
267
|
|
|
129
268
|
/** Collect all preset chips to render */
|
|
130
269
|
const presetChips = React.useMemo(() => {
|
|
@@ -135,9 +274,9 @@ export function DataTableFilter({
|
|
|
135
274
|
for (const [categoryId, values] of Object.entries(presetFilters)) {
|
|
136
275
|
const category = categories.find((c) => c.id === categoryId)
|
|
137
276
|
for (const value of values) {
|
|
138
|
-
const option = category
|
|
139
|
-
(opt) => getOptionValue(opt) === value
|
|
140
|
-
|
|
277
|
+
const option = category && !isTextFilterCategory(category)
|
|
278
|
+
? category.options.find((opt) => getOptionValue(opt) === value)
|
|
279
|
+
: undefined
|
|
141
280
|
const label = option ? getOptionLabel(option) : value
|
|
142
281
|
const active = selectedFilters[categoryId]?.includes(value) ?? false
|
|
143
282
|
chips.push({ categoryId, value, label, active })
|
|
@@ -208,6 +347,18 @@ export function DataTableFilter({
|
|
|
208
347
|
)
|
|
209
348
|
}
|
|
210
349
|
|
|
350
|
+
/* ── Free-text submenu ───────────────────────────────── */
|
|
351
|
+
if (isTextFilterCategory(category)) {
|
|
352
|
+
return (
|
|
353
|
+
<TextFilterSubmenu
|
|
354
|
+
key={category.id}
|
|
355
|
+
category={category}
|
|
356
|
+
value={textFilters[category.id] ?? ""}
|
|
357
|
+
onValueChange={onTextFilterChange}
|
|
358
|
+
/>
|
|
359
|
+
)
|
|
360
|
+
}
|
|
361
|
+
|
|
211
362
|
/* ── Sub-menu (single / multi) ──────────────────────── */
|
|
212
363
|
const subQuery = (subQueries[category.id] ?? "").trim().toLowerCase()
|
|
213
364
|
const filteredOptions = subQuery
|