@handled-ai/design-system 0.18.11 → 0.18.12
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/account-contacts-popover.d.ts +5 -1
- package/dist/components/account-contacts-popover.js +25 -4
- package/dist/components/account-contacts-popover.js.map +1 -1
- package/dist/components/data-table-condition-filter.d.ts +2 -1
- package/dist/components/data-table-condition-filter.js +23 -41
- package/dist/components/data-table-condition-filter.js.map +1 -1
- package/dist/components/data-table-filter.js +8 -9
- package/dist/components/data-table-filter.js.map +1 -1
- package/dist/components/entity-panel.d.ts +2 -1
- package/dist/components/entity-panel.js +52 -45
- package/dist/components/entity-panel.js.map +1 -1
- package/dist/components/score-why-chips.d.ts +1 -1
- package/dist/components/signal-priority-popover.d.ts +1 -1
- package/dist/components/signal-priority-popover.js +4 -4
- package/dist/components/signal-priority-popover.js.map +1 -1
- package/dist/index.d.ts +2 -2
- 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 +5 -3
- package/dist/prototype/prototype-inbox-view.js +11 -5
- 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-BT6CPYNs.d.ts → signal-priority-popover-BEDoPsNE.d.ts} +6 -0
- package/package.json +1 -2
- package/src/components/__tests__/account-contacts-popover.test.tsx +79 -0
- package/src/components/__tests__/data-table-condition-filter.test.tsx +96 -0
- package/src/components/__tests__/data-table-filter.test.tsx +45 -0
- package/src/components/__tests__/entity-panel-header.test.tsx +44 -0
- package/src/components/__tests__/signal-priority-popover.test.tsx +30 -0
- package/src/components/account-contacts-popover.tsx +29 -1
- package/src/components/data-table-condition-filter.tsx +32 -47
- package/src/components/data-table-filter.tsx +7 -10
- package/src/components/entity-panel.tsx +56 -40
- package/src/components/signal-priority-popover.tsx +15 -4
- package/src/prototype/__tests__/detail-view-title-slots.test.tsx +15 -0
- package/src/prototype/prototype-config.ts +2 -0
- package/src/prototype/prototype-inbox-view.tsx +17 -5
|
@@ -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-BEDoPsNE.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-BT6CPYNs.d.ts → signal-priority-popover-BEDoPsNE.d.ts}
RENAMED
|
@@ -230,6 +230,8 @@ interface InboxViewConfig {
|
|
|
230
230
|
attentionCount?: number;
|
|
231
231
|
/** Render extra content inline with the detail title. */
|
|
232
232
|
renderTitleExtra?: (item: QueueItem) => React.ReactNode;
|
|
233
|
+
/** Render a full-width action row below the detail title row. */
|
|
234
|
+
renderTitleActionRow?: (item: QueueItem) => React.ReactNode;
|
|
233
235
|
/** Render supporting content below the detail title. */
|
|
234
236
|
renderTitleSubtext?: (item: QueueItem) => React.ReactNode;
|
|
235
237
|
/** Sort options for the inbox. When provided, a sort dropdown is rendered in the split view toolbar. */
|
|
@@ -397,8 +399,12 @@ interface PriorityFactor {
|
|
|
397
399
|
tone: "alert" | "warn" | "info";
|
|
398
400
|
/** Explicit semantic label - NOT inferred from score+weight. */
|
|
399
401
|
direction: "raises" | "lowers" | "neutral";
|
|
402
|
+
/** Optional display label for the direction text. Keeps semantic direction icon/color unchanged. */
|
|
403
|
+
directionLabel?: string;
|
|
400
404
|
/** 0-100 */
|
|
401
405
|
score: number;
|
|
406
|
+
/** Optional display label rendered instead of the numeric score cell. */
|
|
407
|
+
displayValueLabel?: string;
|
|
402
408
|
/** Evidence text (e.g. "$3.4M moved in 8h - current treasury balance $0.00"). */
|
|
403
409
|
rationale: string;
|
|
404
410
|
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest"
|
|
2
|
+
import React from "react"
|
|
3
|
+
import { render, screen, fireEvent } from "@testing-library/react"
|
|
4
|
+
|
|
5
|
+
import { AccountContactsPopover } from "../account-contacts-popover"
|
|
6
|
+
import type { SuggestedContact } from "../suggested-actions"
|
|
7
|
+
|
|
8
|
+
const contacts: SuggestedContact[] = [
|
|
9
|
+
{
|
|
10
|
+
name: "Alex Admin",
|
|
11
|
+
role: "Controller",
|
|
12
|
+
email: "alex@example.com",
|
|
13
|
+
confirmed: true,
|
|
14
|
+
},
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
describe("AccountContactsPopover", () => {
|
|
18
|
+
it("keeps default additive row selection behavior", () => {
|
|
19
|
+
const onSelect = vi.fn()
|
|
20
|
+
const onSelectTo = vi.fn()
|
|
21
|
+
|
|
22
|
+
render(
|
|
23
|
+
<AccountContactsPopover
|
|
24
|
+
contacts={contacts}
|
|
25
|
+
onSelect={onSelect}
|
|
26
|
+
onSelectTo={onSelectTo}
|
|
27
|
+
trigger={<button type="button">Contacts</button>}
|
|
28
|
+
/>,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
fireEvent.click(screen.getByRole("button", { name: "Contacts" }))
|
|
32
|
+
fireEvent.click(screen.getByText("Alex Admin"))
|
|
33
|
+
|
|
34
|
+
expect(onSelectTo).toHaveBeenCalledWith(contacts[0])
|
|
35
|
+
expect(onSelect).not.toHaveBeenCalled()
|
|
36
|
+
expect(screen.queryByRole("button", { name: "Add" })).toBeNull()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it("uses switch selection for default row clicks when onSelectSwitch is provided", () => {
|
|
40
|
+
const onSelect = vi.fn()
|
|
41
|
+
const onSelectTo = vi.fn()
|
|
42
|
+
const onSelectSwitch = vi.fn()
|
|
43
|
+
|
|
44
|
+
render(
|
|
45
|
+
<AccountContactsPopover
|
|
46
|
+
contacts={contacts}
|
|
47
|
+
onSelect={onSelect}
|
|
48
|
+
onSelectTo={onSelectTo}
|
|
49
|
+
onSelectSwitch={onSelectSwitch}
|
|
50
|
+
trigger={<button type="button">Contacts</button>}
|
|
51
|
+
/>,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
fireEvent.click(screen.getByRole("button", { name: "Contacts" }))
|
|
55
|
+
fireEvent.click(screen.getByRole("button", { name: /switch alex admin/i }))
|
|
56
|
+
|
|
57
|
+
expect(onSelectSwitch).toHaveBeenCalledWith(contacts[0])
|
|
58
|
+
expect(onSelectTo).not.toHaveBeenCalled()
|
|
59
|
+
expect(onSelect).not.toHaveBeenCalled()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it("allows custom default select label for switch action copy", () => {
|
|
63
|
+
const onSelect = vi.fn()
|
|
64
|
+
const onSelectSwitch = vi.fn()
|
|
65
|
+
|
|
66
|
+
render(
|
|
67
|
+
<AccountContactsPopover
|
|
68
|
+
contacts={contacts}
|
|
69
|
+
onSelect={onSelect}
|
|
70
|
+
onSelectSwitch={onSelectSwitch}
|
|
71
|
+
defaultSelectLabel="Replace"
|
|
72
|
+
trigger={<button type="button">Contacts</button>}
|
|
73
|
+
/>,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
fireEvent.click(screen.getByRole("button", { name: "Contacts" }))
|
|
77
|
+
expect(screen.getByRole("button", { name: /replace alex admin/i })).toBeTruthy()
|
|
78
|
+
})
|
|
79
|
+
})
|
|
@@ -161,6 +161,14 @@ describe("DataTableConditionFilter", () => {
|
|
|
161
161
|
fireEvent.click(fieldOption!);
|
|
162
162
|
};
|
|
163
163
|
|
|
164
|
+
const expectFieldTriggerHasNoFieldTypeIcon = (container: HTMLElement) => {
|
|
165
|
+
const fieldTrigger = container.querySelector('[data-slot="condition-row"] [data-slot="select-trigger"]');
|
|
166
|
+
expect(fieldTrigger).not.toBeNull();
|
|
167
|
+
expect(fieldTrigger?.querySelector("svg.lucide-ellipsis")).toBeNull();
|
|
168
|
+
expect(fieldTrigger?.querySelector("svg.lucide-list-filter")).toBeNull();
|
|
169
|
+
expect(fieldTrigger?.querySelector("svg.lucide-list-checks")).toBeNull();
|
|
170
|
+
};
|
|
171
|
+
|
|
164
172
|
it("renders a polished empty panel with Add filter, disabled Add filter group, and quiet Clear filters", () => {
|
|
165
173
|
const { container } = render(
|
|
166
174
|
<DataTableConditionFilter
|
|
@@ -244,6 +252,8 @@ describe("DataTableConditionFilter", () => {
|
|
|
244
252
|
const { container } = renderOptionFilter();
|
|
245
253
|
chooseField(container, "Stage");
|
|
246
254
|
|
|
255
|
+
expectFieldTriggerHasNoFieldTypeIcon(container);
|
|
256
|
+
|
|
247
257
|
expect(getOperators(selectField)).toEqual(["eq", "neq", "is_null", "is_not_null"]);
|
|
248
258
|
fireEvent.click(screen.getByText("Qualified"));
|
|
249
259
|
fireEvent.click(screen.getByText("Apply"));
|
|
@@ -295,10 +305,96 @@ describe("DataTableConditionFilter", () => {
|
|
|
295
305
|
});
|
|
296
306
|
});
|
|
297
307
|
|
|
308
|
+
it("uses DataTableFilter threshold semantics for option search", () => {
|
|
309
|
+
const thresholdField: ConditionFieldDef = {
|
|
310
|
+
id: "threshold",
|
|
311
|
+
label: "Threshold",
|
|
312
|
+
type: "select",
|
|
313
|
+
searchable: { threshold: 2 },
|
|
314
|
+
options: [
|
|
315
|
+
{ label: "One", value: "one" },
|
|
316
|
+
{ label: "Two", value: "two" },
|
|
317
|
+
],
|
|
318
|
+
};
|
|
319
|
+
const aboveThresholdField: ConditionFieldDef = {
|
|
320
|
+
...thresholdField,
|
|
321
|
+
id: "above_threshold",
|
|
322
|
+
options: [
|
|
323
|
+
{ label: "One", value: "one" },
|
|
324
|
+
{ label: "Two", value: "two" },
|
|
325
|
+
{ label: "Three", value: "three" },
|
|
326
|
+
],
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
const { rerender } = render(
|
|
330
|
+
<DataTableConditionFilter
|
|
331
|
+
fields={[thresholdField]}
|
|
332
|
+
conditions={[]}
|
|
333
|
+
onConditionsChange={onConditionsChange}
|
|
334
|
+
/>,
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
fireEvent.click(screen.getByText("Add filter"));
|
|
338
|
+
expect(screen.queryByPlaceholderText("Search options...")).toBeNull();
|
|
339
|
+
|
|
340
|
+
rerender(
|
|
341
|
+
<DataTableConditionFilter
|
|
342
|
+
fields={[aboveThresholdField]}
|
|
343
|
+
conditions={[]}
|
|
344
|
+
onConditionsChange={onConditionsChange}
|
|
345
|
+
/>,
|
|
346
|
+
);
|
|
347
|
+
expect(screen.getByPlaceholderText("Search options...")).toBeDefined();
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it("does not apply a stale search query after switching to a non-searchable select field", () => {
|
|
351
|
+
const searchableOwnerField: ConditionFieldDef = {
|
|
352
|
+
id: "owner",
|
|
353
|
+
label: "Owner",
|
|
354
|
+
type: "select",
|
|
355
|
+
searchable: true,
|
|
356
|
+
options: [
|
|
357
|
+
{ label: "Alice Adams", value: "alice@example.com" },
|
|
358
|
+
{ label: "Bob Brown", value: "bob@example.com" },
|
|
359
|
+
],
|
|
360
|
+
};
|
|
361
|
+
const statusField: ConditionFieldDef = {
|
|
362
|
+
id: "status",
|
|
363
|
+
label: "Status",
|
|
364
|
+
type: "select",
|
|
365
|
+
searchable: false,
|
|
366
|
+
options: [
|
|
367
|
+
{ label: "Active", value: "active" },
|
|
368
|
+
{ label: "Inactive", value: "inactive" },
|
|
369
|
+
],
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
const { container } = render(
|
|
373
|
+
<DataTableConditionFilter
|
|
374
|
+
fields={[searchableOwnerField, statusField]}
|
|
375
|
+
conditions={[]}
|
|
376
|
+
onConditionsChange={onConditionsChange}
|
|
377
|
+
/>,
|
|
378
|
+
);
|
|
379
|
+
|
|
380
|
+
fireEvent.click(screen.getByText("Add filter"));
|
|
381
|
+
fireEvent.change(screen.getByPlaceholderText("Search options..."), {
|
|
382
|
+
target: { value: "bob" },
|
|
383
|
+
});
|
|
384
|
+
chooseField(container, "Status");
|
|
385
|
+
|
|
386
|
+
expect(screen.queryByPlaceholderText("Search options...")).toBeNull();
|
|
387
|
+
expect(screen.getByText("Active")).toBeDefined();
|
|
388
|
+
expect(screen.getByText("Inactive")).toBeDefined();
|
|
389
|
+
expect(screen.queryByText("No options")).toBeNull();
|
|
390
|
+
});
|
|
391
|
+
|
|
298
392
|
it("renders multi-select checkboxes, toggles multiple options, and commits an in condition", () => {
|
|
299
393
|
const { container } = renderOptionFilter();
|
|
300
394
|
chooseField(container, "Industry");
|
|
301
395
|
|
|
396
|
+
expectFieldTriggerHasNoFieldTypeIcon(container);
|
|
397
|
+
|
|
302
398
|
expect(getOperators(multiSelectField)).toEqual(["in", "is_null", "is_not_null"]);
|
|
303
399
|
|
|
304
400
|
const financeCheckbox = screen.getByRole("checkbox", { name: "Finance" });
|
|
@@ -367,4 +367,49 @@ describe("DataTableFilter", () => {
|
|
|
367
367
|
|
|
368
368
|
expect(screen.getByText("Advanced filters")).toBeDefined();
|
|
369
369
|
});
|
|
370
|
+
|
|
371
|
+
it("renders badge with bg-muted and text-foreground when activeCount > 0", () => {
|
|
372
|
+
render(
|
|
373
|
+
<DataTableFilter
|
|
374
|
+
categories={[
|
|
375
|
+
{
|
|
376
|
+
id: "status",
|
|
377
|
+
label: "Status",
|
|
378
|
+
icon: ListFilter,
|
|
379
|
+
options: ["Open", "Closed"],
|
|
380
|
+
},
|
|
381
|
+
]}
|
|
382
|
+
selectedFilters={{ status: ["Open"] }}
|
|
383
|
+
onToggleFilter={() => {}}
|
|
384
|
+
/>
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
const badge = screen.getByText("1");
|
|
388
|
+
expect(badge.tagName).toBe("SPAN");
|
|
389
|
+
expect(badge.className).toContain("bg-muted");
|
|
390
|
+
expect(badge.className).toContain("text-foreground");
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it("does not render badge when activeCount is 0", () => {
|
|
394
|
+
const { container } = render(
|
|
395
|
+
<DataTableFilter
|
|
396
|
+
categories={[
|
|
397
|
+
{
|
|
398
|
+
id: "status",
|
|
399
|
+
label: "Status",
|
|
400
|
+
icon: ListFilter,
|
|
401
|
+
options: ["Open", "Closed"],
|
|
402
|
+
},
|
|
403
|
+
]}
|
|
404
|
+
selectedFilters={{}}
|
|
405
|
+
onToggleFilter={() => {}}
|
|
406
|
+
/>
|
|
407
|
+
);
|
|
408
|
+
|
|
409
|
+
const button = container.querySelector("button");
|
|
410
|
+
expect(button).not.toBeNull();
|
|
411
|
+
// The button should contain "Filter" text but no badge span with a count
|
|
412
|
+
const spans = button!.querySelectorAll("span.bg-muted");
|
|
413
|
+
expect(spans.length).toBe(0);
|
|
414
|
+
});
|
|
370
415
|
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest"
|
|
2
|
+
import React from "react"
|
|
3
|
+
import { render, screen } from "@testing-library/react"
|
|
4
|
+
|
|
5
|
+
import { EntityPanel, EntityPanelHeader } from "../entity-panel"
|
|
6
|
+
|
|
7
|
+
function renderHeader(node: React.ReactNode) {
|
|
8
|
+
return render(
|
|
9
|
+
<EntityPanel isOpen onClose={vi.fn()}>
|
|
10
|
+
{node}
|
|
11
|
+
</EntityPanel>,
|
|
12
|
+
)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe("EntityPanelHeader", () => {
|
|
16
|
+
it("preserves headerAction and panel controls", () => {
|
|
17
|
+
renderHeader(
|
|
18
|
+
<EntityPanelHeader
|
|
19
|
+
title="Acme Corp"
|
|
20
|
+
headerAction={<button type="button">Mercury Admin</button>}
|
|
21
|
+
/>,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
expect(screen.getByText("Acme Corp")).toBeTruthy()
|
|
25
|
+
expect(screen.getByRole("button", { name: "Mercury Admin" })).toBeTruthy()
|
|
26
|
+
expect(screen.getByTitle("Copy Link")).toBeTruthy()
|
|
27
|
+
expect(screen.getByTitle("Wide")).toBeTruthy()
|
|
28
|
+
expect(screen.getByTitle("Close")).toBeTruthy()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it("renders subtitle and secondary action on the secondary row", () => {
|
|
32
|
+
renderHeader(
|
|
33
|
+
<EntityPanelHeader
|
|
34
|
+
title="Acme Corp"
|
|
35
|
+
subtitle="Preferred account · Treasury"
|
|
36
|
+
headerSecondaryAction={<button type="button">Quick action</button>}
|
|
37
|
+
/>,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
expect(screen.getByText("Preferred account · Treasury")).toBeTruthy()
|
|
41
|
+
expect(screen.getByRole("button", { name: "Quick action" })).toBeTruthy()
|
|
42
|
+
expect(screen.getByTitle("Copy Link")).toBeTruthy()
|
|
43
|
+
})
|
|
44
|
+
})
|
|
@@ -157,6 +157,36 @@ describe("SignalPriorityPopover", () => {
|
|
|
157
157
|
expect(neutralRow.textContent).toContain("50")
|
|
158
158
|
})
|
|
159
159
|
|
|
160
|
+
|
|
161
|
+
it("keeps default direction and score labels unchanged", () => {
|
|
162
|
+
render(<SignalPriorityPopover {...defaultProps} />)
|
|
163
|
+
fireEvent.click(screen.getByTestId("priority-popover-trigger"))
|
|
164
|
+
|
|
165
|
+
const row = screen.getByTestId("factor-row-test_severity")
|
|
166
|
+
expect(row.textContent).toContain("Raises")
|
|
167
|
+
expect(row.textContent).toContain("85")
|
|
168
|
+
expect(row.textContent).toContain("/100")
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it("renders custom direction and display value labels", () => {
|
|
172
|
+
const customFactors: PriorityFactor[] = [
|
|
173
|
+
{
|
|
174
|
+
...mockFactors[0],
|
|
175
|
+
directionLabel: "Raises urgency",
|
|
176
|
+
displayValueLabel: "Open window",
|
|
177
|
+
score: 0,
|
|
178
|
+
},
|
|
179
|
+
]
|
|
180
|
+
|
|
181
|
+
render(<SignalPriorityPopover {...defaultProps} factors={customFactors} />)
|
|
182
|
+
fireEvent.click(screen.getByTestId("priority-popover-trigger"))
|
|
183
|
+
|
|
184
|
+
const row = screen.getByTestId("factor-row-test_severity")
|
|
185
|
+
expect(row.textContent).toContain("Raises urgency")
|
|
186
|
+
expect(row.textContent).toContain("Open window")
|
|
187
|
+
expect(row.textContent).not.toContain("Raises0/100")
|
|
188
|
+
})
|
|
189
|
+
|
|
160
190
|
it("renders Contributing factors section label", () => {
|
|
161
191
|
render(<SignalPriorityPopover {...defaultProps} />)
|
|
162
192
|
fireEvent.click(screen.getByTestId("priority-popover-trigger"))
|
|
@@ -32,6 +32,10 @@ export interface AccountContactsPopoverProps {
|
|
|
32
32
|
onSelectTo?: (contact: SuggestedContact) => void
|
|
33
33
|
onSelectCc?: (contact: SuggestedContact) => void
|
|
34
34
|
onSelectBcc?: (contact: SuggestedContact) => void
|
|
35
|
+
/** Label for the default contact row action. Defaults to "Add" or "Switch" when onSelectSwitch is provided. */
|
|
36
|
+
defaultSelectLabel?: string
|
|
37
|
+
/** Optional replacement-selection callback. When provided, row clicks call this instead of additive onSelect/onSelectTo. */
|
|
38
|
+
onSelectSwitch?: (contact: SuggestedContact) => void
|
|
35
39
|
onViewAll?: () => void
|
|
36
40
|
onOpenRecentActivity?: () => void
|
|
37
41
|
trigger: React.ReactNode
|
|
@@ -44,6 +48,8 @@ export function AccountContactsPopover({
|
|
|
44
48
|
onSelectTo,
|
|
45
49
|
onSelectCc,
|
|
46
50
|
onSelectBcc,
|
|
51
|
+
defaultSelectLabel,
|
|
52
|
+
onSelectSwitch,
|
|
47
53
|
onViewAll,
|
|
48
54
|
onOpenRecentActivity,
|
|
49
55
|
trigger,
|
|
@@ -52,6 +58,15 @@ export function AccountContactsPopover({
|
|
|
52
58
|
const [open, setOpen] = React.useState(false)
|
|
53
59
|
const triggerRef = React.useRef<HTMLDivElement>(null)
|
|
54
60
|
const [popoverStyle, setPopoverStyle] = React.useState<React.CSSProperties>({})
|
|
61
|
+
const resolvedDefaultSelectLabel = defaultSelectLabel ?? (onSelectSwitch ? "Switch" : undefined)
|
|
62
|
+
const handleDefaultSelect = React.useCallback((contact: SuggestedContact) => {
|
|
63
|
+
if (onSelectSwitch) {
|
|
64
|
+
onSelectSwitch(contact)
|
|
65
|
+
} else {
|
|
66
|
+
(onSelectTo ?? onSelect)(contact)
|
|
67
|
+
}
|
|
68
|
+
setOpen(false)
|
|
69
|
+
}, [onSelect, onSelectSwitch, onSelectTo])
|
|
55
70
|
|
|
56
71
|
React.useEffect(() => {
|
|
57
72
|
if (open && triggerRef.current) {
|
|
@@ -84,7 +99,8 @@ export function AccountContactsPopover({
|
|
|
84
99
|
<div
|
|
85
100
|
key={i}
|
|
86
101
|
role="button"
|
|
87
|
-
onClick={() =>
|
|
102
|
+
onClick={() => handleDefaultSelect(c)}
|
|
103
|
+
aria-label={resolvedDefaultSelectLabel ? `${resolvedDefaultSelectLabel} ${c.name}` : undefined}
|
|
88
104
|
className="flex items-center gap-3 w-full px-3 py-2 text-left hover:bg-muted/50 transition-colors cursor-pointer"
|
|
89
105
|
>
|
|
90
106
|
<div className="w-7 h-7 rounded-full bg-muted flex items-center justify-center text-[10px] font-medium text-muted-foreground shrink-0">
|
|
@@ -115,6 +131,18 @@ export function AccountContactsPopover({
|
|
|
115
131
|
)}
|
|
116
132
|
</div>
|
|
117
133
|
<div className="ml-2 flex items-center gap-1.5 shrink-0">
|
|
134
|
+
{resolvedDefaultSelectLabel && (
|
|
135
|
+
<button
|
|
136
|
+
type="button"
|
|
137
|
+
onClick={(e) => {
|
|
138
|
+
e.stopPropagation()
|
|
139
|
+
handleDefaultSelect(c)
|
|
140
|
+
}}
|
|
141
|
+
className="h-6 rounded border border-border bg-background px-1.5 text-[10px] text-muted-foreground hover:text-foreground hover:bg-muted/40"
|
|
142
|
+
>
|
|
143
|
+
{resolvedDefaultSelectLabel}
|
|
144
|
+
</button>
|
|
145
|
+
)}
|
|
118
146
|
{onSelectTo && (
|
|
119
147
|
<button
|
|
120
148
|
type="button"
|
|
@@ -2,16 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
import * as React from "react"
|
|
4
4
|
import {
|
|
5
|
-
CalendarDays,
|
|
6
5
|
Check,
|
|
7
|
-
DollarSign,
|
|
8
6
|
Eye,
|
|
9
|
-
Hash,
|
|
10
7
|
MoreHorizontal,
|
|
11
8
|
Plus,
|
|
12
9
|
Trash2,
|
|
13
|
-
Type,
|
|
14
|
-
type LucideIcon,
|
|
15
10
|
} from "lucide-react"
|
|
16
11
|
|
|
17
12
|
import { cn } from "../lib/utils"
|
|
@@ -103,6 +98,22 @@ const NUMERIC_OPERATORS: ConditionOperator[] = [
|
|
|
103
98
|
"is_not_null",
|
|
104
99
|
]
|
|
105
100
|
|
|
101
|
+
const NAV_KEYS = new Set(["ArrowDown", "ArrowUp", "Enter", "Escape", "Tab"])
|
|
102
|
+
|
|
103
|
+
export function shouldShowOptionSearch(
|
|
104
|
+
searchable: ConditionFieldDef["searchable"],
|
|
105
|
+
optionCount: number,
|
|
106
|
+
defaultThreshold = 8,
|
|
107
|
+
): boolean {
|
|
108
|
+
if (searchable === true) return true
|
|
109
|
+
if (searchable === false) return false
|
|
110
|
+
const threshold =
|
|
111
|
+
typeof searchable === "object"
|
|
112
|
+
? (searchable.threshold ?? defaultThreshold)
|
|
113
|
+
: defaultThreshold
|
|
114
|
+
return optionCount > threshold
|
|
115
|
+
}
|
|
116
|
+
|
|
106
117
|
const DEFAULT_OPERATORS: Record<ConditionFieldDef["type"], ConditionOperator[]> = {
|
|
107
118
|
text: ["eq", "neq", "is_null", "is_not_null"],
|
|
108
119
|
number: NUMERIC_OPERATORS,
|
|
@@ -241,19 +252,6 @@ function getCommittedConditions(
|
|
|
241
252
|
.filter((condition) => isCompleteCondition(condition, fields))
|
|
242
253
|
}
|
|
243
254
|
|
|
244
|
-
const FIELD_ICON_BY_TYPE: Record<ConditionFieldDef["type"], LucideIcon> = {
|
|
245
|
-
text: Type,
|
|
246
|
-
number: Hash,
|
|
247
|
-
currency: DollarSign,
|
|
248
|
-
date: CalendarDays,
|
|
249
|
-
select: MoreHorizontal,
|
|
250
|
-
multi_select: MoreHorizontal,
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
function getFieldIcon(type: ConditionFieldDef["type"]): LucideIcon {
|
|
254
|
-
return FIELD_ICON_BY_TYPE[type]
|
|
255
|
-
}
|
|
256
|
-
|
|
257
255
|
// ── Condition Row ──────────────────────────────────────────────
|
|
258
256
|
|
|
259
257
|
function getInputType(fieldType: ConditionFieldDef["type"]): "text" | "number" | "date" {
|
|
@@ -278,18 +276,6 @@ interface ConditionValueInputProps {
|
|
|
278
276
|
onCommit: () => void
|
|
279
277
|
}
|
|
280
278
|
|
|
281
|
-
function shouldShowOptionSearch(
|
|
282
|
-
fieldDef: ConditionFieldDef,
|
|
283
|
-
optionCount: number,
|
|
284
|
-
): boolean {
|
|
285
|
-
if (fieldDef.searchable === true) return true
|
|
286
|
-
if (fieldDef.searchable === false) return false
|
|
287
|
-
if (typeof fieldDef.searchable === "object") {
|
|
288
|
-
return optionCount >= (fieldDef.searchable.threshold ?? 8)
|
|
289
|
-
}
|
|
290
|
-
return false
|
|
291
|
-
}
|
|
292
|
-
|
|
293
279
|
function SelectConditionValueInput({
|
|
294
280
|
condition,
|
|
295
281
|
fieldDef,
|
|
@@ -297,11 +283,15 @@ function SelectConditionValueInput({
|
|
|
297
283
|
}: Pick<ConditionValueInputProps, "condition" | "fieldDef" | "onSelectValueChange">) {
|
|
298
284
|
const [query, setQuery] = React.useState("")
|
|
299
285
|
const options = normalizeFieldOptions(fieldDef)
|
|
300
|
-
const
|
|
286
|
+
const showSearch = shouldShowOptionSearch(fieldDef.searchable, options.length)
|
|
287
|
+
const normalizedQuery = showSearch ? query.trim().toLowerCase() : ""
|
|
301
288
|
const filteredOptions = normalizedQuery
|
|
302
289
|
? options.filter((option) => option.label.toLowerCase().includes(normalizedQuery))
|
|
303
290
|
: options
|
|
304
|
-
|
|
291
|
+
|
|
292
|
+
React.useEffect(() => {
|
|
293
|
+
setQuery("")
|
|
294
|
+
}, [fieldDef.id])
|
|
305
295
|
|
|
306
296
|
return (
|
|
307
297
|
<Select
|
|
@@ -318,7 +308,11 @@ function SelectConditionValueInput({
|
|
|
318
308
|
value={query}
|
|
319
309
|
onChange={(event) => setQuery(event.target.value)}
|
|
320
310
|
onClick={(event) => event.stopPropagation()}
|
|
321
|
-
onKeyDown={(event) =>
|
|
311
|
+
onKeyDown={(event) => {
|
|
312
|
+
if (!NAV_KEYS.has(event.key)) {
|
|
313
|
+
event.stopPropagation()
|
|
314
|
+
}
|
|
315
|
+
}}
|
|
322
316
|
placeholder="Search options..."
|
|
323
317
|
className="h-7 text-xs"
|
|
324
318
|
/>
|
|
@@ -453,8 +447,6 @@ function ConditionRow({
|
|
|
453
447
|
const fieldDef = fields.find((f) => f.id === condition.field) ?? fields[0]
|
|
454
448
|
const operators = getOperators(fieldDef)
|
|
455
449
|
const isUnary = isUnaryOperator(condition.operator)
|
|
456
|
-
const FieldIcon = getFieldIcon(fieldDef.type)
|
|
457
|
-
|
|
458
450
|
const handleFieldChange = (newFieldId: string) => {
|
|
459
451
|
const newFieldDef = fields.find((f) => f.id === newFieldId) ?? fields[0]
|
|
460
452
|
if (!newFieldDef) return
|
|
@@ -512,21 +504,14 @@ function ConditionRow({
|
|
|
512
504
|
|
|
513
505
|
<Select value={condition.field} onValueChange={handleFieldChange}>
|
|
514
506
|
<SelectTrigger className="h-8 w-full justify-start gap-2" size="sm">
|
|
515
|
-
<FieldIcon className="h-3.5 w-3.5 text-muted-foreground" />
|
|
516
507
|
<SelectValue placeholder={fieldDef.label} />
|
|
517
508
|
</SelectTrigger>
|
|
518
509
|
<SelectContent>
|
|
519
|
-
{fields.map((field) =>
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
|
|
525
|
-
{field.label}
|
|
526
|
-
</span>
|
|
527
|
-
</SelectItem>
|
|
528
|
-
)
|
|
529
|
-
})}
|
|
510
|
+
{fields.map((field) => (
|
|
511
|
+
<SelectItem key={field.id} value={field.id}>
|
|
512
|
+
{field.label}
|
|
513
|
+
</SelectItem>
|
|
514
|
+
))}
|
|
530
515
|
</SelectContent>
|
|
531
516
|
</Select>
|
|
532
517
|
|
|
@@ -9,6 +9,7 @@ import { cn } from "../lib/utils"
|
|
|
9
9
|
import { Button } from "./button"
|
|
10
10
|
import {
|
|
11
11
|
DataTableConditionFilter,
|
|
12
|
+
shouldShowOptionSearch,
|
|
12
13
|
type ConditionFieldDef,
|
|
13
14
|
type ConditionFilterValue,
|
|
14
15
|
} from "./data-table-condition-filter"
|
|
@@ -160,7 +161,7 @@ export function DataTableFilter({
|
|
|
160
161
|
<ListFilter className="h-3.5 w-3.5" />
|
|
161
162
|
Filter
|
|
162
163
|
{activeCount > 0 ? (
|
|
163
|
-
<span className="rounded bg-muted px-1.5 py-0 text-[10px] font-semibold">
|
|
164
|
+
<span className="rounded bg-muted px-1.5 py-0 text-[10px] font-semibold text-foreground">
|
|
164
165
|
{activeCount}
|
|
165
166
|
</span>
|
|
166
167
|
) : null}
|
|
@@ -214,15 +215,11 @@ export function DataTableFilter({
|
|
|
214
215
|
getOptionLabel(opt).toLowerCase().includes(subQuery)
|
|
215
216
|
)
|
|
216
217
|
: category.options
|
|
217
|
-
const shouldShowSubmenuSearch = (
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
? (category.searchable.threshold ?? optionSearchThreshold)
|
|
223
|
-
: optionSearchThreshold
|
|
224
|
-
return category.options.length > threshold
|
|
225
|
-
})()
|
|
218
|
+
const shouldShowSubmenuSearch = shouldShowOptionSearch(
|
|
219
|
+
category.searchable,
|
|
220
|
+
category.options.length,
|
|
221
|
+
optionSearchThreshold,
|
|
222
|
+
)
|
|
226
223
|
|
|
227
224
|
return (
|
|
228
225
|
<DropdownMenuSub
|