@handled-ai/design-system 0.15.1 → 0.16.1

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.
Files changed (47) hide show
  1. package/dist/components/badge.d.ts +1 -1
  2. package/dist/components/button.d.ts +1 -1
  3. package/dist/components/collapsible-section.d.ts +20 -0
  4. package/dist/components/collapsible-section.js +48 -0
  5. package/dist/components/collapsible-section.js.map +1 -0
  6. package/dist/components/contact-list.d.ts +3 -1
  7. package/dist/components/contact-list.js +20 -3
  8. package/dist/components/contact-list.js.map +1 -1
  9. package/dist/components/data-table-condition-filter.d.ts +37 -0
  10. package/dist/components/data-table-condition-filter.js +407 -0
  11. package/dist/components/data-table-condition-filter.js.map +1 -0
  12. package/dist/components/data-table-filter.d.ts +19 -2
  13. package/dist/components/data-table-filter.js +160 -13
  14. package/dist/components/data-table-filter.js.map +1 -1
  15. package/dist/components/data-table-toolbar.d.ts +1 -0
  16. package/dist/components/data-table.d.ts +1 -0
  17. package/dist/components/entity-panel.js +1 -1
  18. package/dist/components/entity-panel.js.map +1 -1
  19. package/dist/components/tabs.d.ts +1 -1
  20. package/dist/index.d.ts +3 -1
  21. package/dist/index.js +3 -0
  22. package/dist/index.js.map +1 -1
  23. package/dist/prototype/index.d.ts +1 -0
  24. package/dist/prototype/prototype-accounts-view.d.ts +1 -0
  25. package/dist/prototype/prototype-admin-view.d.ts +1 -0
  26. package/dist/prototype/prototype-config.d.ts +1 -0
  27. package/dist/prototype/prototype-inbox-view.d.ts +4 -1
  28. package/dist/prototype/prototype-inbox-view.js +6 -1
  29. package/dist/prototype/prototype-inbox-view.js.map +1 -1
  30. package/dist/prototype/prototype-insights-view.d.ts +1 -0
  31. package/dist/prototype/prototype-shell.d.ts +1 -0
  32. package/package.json +1 -1
  33. package/src/components/__tests__/collapsible-section.test.tsx +143 -0
  34. package/src/components/__tests__/contact-list.test.tsx +116 -0
  35. package/src/components/__tests__/data-table-condition-filter.test.tsx +397 -0
  36. package/src/components/__tests__/data-table-filter-presets.test.tsx +209 -0
  37. package/src/components/__tests__/data-table-filter.test.tsx +270 -3
  38. package/src/components/__tests__/entity-metadata-grid.test.tsx +25 -0
  39. package/src/components/__tests__/virtualized-data-table.test.tsx +0 -1
  40. package/src/components/collapsible-section.tsx +62 -0
  41. package/src/components/contact-list.tsx +22 -3
  42. package/src/components/data-table-condition-filter.tsx +513 -0
  43. package/src/components/data-table-filter.tsx +201 -13
  44. package/src/components/entity-panel.tsx +1 -1
  45. package/src/index.ts +2 -0
  46. package/src/prototype/__tests__/detail-view-attention.test.tsx +101 -0
  47. package/src/prototype/prototype-inbox-view.tsx +8 -0
@@ -0,0 +1,397 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import React from "react";
3
+ import { render, screen, fireEvent } from "@testing-library/react";
4
+ import {
5
+ DataTableConditionFilter,
6
+ type ConditionFieldDef,
7
+ type ConditionFilterValue,
8
+ generateConditionId,
9
+ getOperators,
10
+ } from "../data-table-condition-filter";
11
+
12
+ // ── Mock Radix Select with inline option elements ───────────────
13
+ vi.mock("../select", () => {
14
+ const SelectMockContext = React.createContext<{
15
+ value?: string;
16
+ onValueChange?: (v: string) => void;
17
+ }>({});
18
+
19
+ const Select = ({
20
+ children,
21
+ value,
22
+ onValueChange,
23
+ }: {
24
+ children: React.ReactNode;
25
+ value?: string;
26
+ onValueChange?: (v: string) => void;
27
+ }) => (
28
+ <SelectMockContext.Provider value={{ value, onValueChange }}>
29
+ {children}
30
+ </SelectMockContext.Provider>
31
+ );
32
+
33
+ const SelectTrigger = ({
34
+ children,
35
+ className,
36
+ }: {
37
+ children: React.ReactNode;
38
+ className?: string;
39
+ size?: string;
40
+ }) => (
41
+ <span data-slot="select-trigger" className={className}>
42
+ {children}
43
+ </span>
44
+ );
45
+
46
+ const SelectValue = ({ placeholder }: { placeholder?: string }) => {
47
+ const ctx = React.useContext(SelectMockContext);
48
+ return <span data-slot="select-value">{ctx.value ?? placeholder}</span>;
49
+ };
50
+
51
+ const SelectContent = ({ children }: { children: React.ReactNode }) => <>{children}</>;
52
+
53
+ const SelectItem = ({
54
+ children,
55
+ value,
56
+ }: {
57
+ children: React.ReactNode;
58
+ value: string;
59
+ }) => {
60
+ const ctx = React.useContext(SelectMockContext);
61
+ return (
62
+ <option
63
+ data-slot="select-item"
64
+ value={value}
65
+ data-selected={ctx.value === value ? "true" : undefined}
66
+ onClick={() => ctx.onValueChange?.(value)}
67
+ >
68
+ {children}
69
+ </option>
70
+ );
71
+ };
72
+
73
+ return {
74
+ Select,
75
+ SelectContent,
76
+ SelectItem,
77
+ SelectTrigger,
78
+ SelectValue,
79
+ };
80
+ });
81
+
82
+ const textField: ConditionFieldDef = {
83
+ id: "name",
84
+ label: "Name",
85
+ type: "text",
86
+ };
87
+
88
+ const numberField: ConditionFieldDef = {
89
+ id: "balance",
90
+ label: "Account Balance",
91
+ type: "number",
92
+ };
93
+
94
+ const currencyField: ConditionFieldDef = {
95
+ id: "revenue",
96
+ label: "Revenue",
97
+ type: "currency",
98
+ };
99
+
100
+ const dateField: ConditionFieldDef = {
101
+ id: "created_at",
102
+ label: "Created At",
103
+ type: "date",
104
+ };
105
+
106
+ const allFields: ConditionFieldDef[] = [
107
+ textField,
108
+ numberField,
109
+ currencyField,
110
+ dateField,
111
+ ];
112
+
113
+ describe("DataTableConditionFilter", () => {
114
+ let onConditionsChange: ReturnType<typeof vi.fn<(conditions: ConditionFilterValue[]) => void>>;
115
+
116
+ beforeEach(() => {
117
+ onConditionsChange = vi.fn<(conditions: ConditionFilterValue[]) => void>();
118
+ });
119
+
120
+ it("renders a polished empty panel with Add filter, disabled Add filter group, and quiet Clear filters", () => {
121
+ const { container } = render(
122
+ <DataTableConditionFilter
123
+ fields={allFields}
124
+ conditions={[]}
125
+ onConditionsChange={onConditionsChange}
126
+ />,
127
+ );
128
+
129
+ expect(screen.getByText("Filter builder")).toBeDefined();
130
+ expect(screen.getByText("No builder filters yet. Add a filter to start a condition row.")).toBeDefined();
131
+ expect(screen.getByText("Add filter")).toBeDefined();
132
+ expect(screen.getByText("Add filter group")).toBeDefined();
133
+ expect(screen.getByText("Add filter group").closest("button")?.hasAttribute("disabled")).toBe(true);
134
+ expect(screen.getByText("Clear filters").closest("button")?.hasAttribute("disabled")).toBe(true);
135
+ expect(container.querySelectorAll('[data-slot="condition-row"]')).toHaveLength(0);
136
+ });
137
+
138
+ it("clicking Add filter creates a draft row without emitting an incomplete condition", () => {
139
+ const { container } = render(
140
+ <DataTableConditionFilter
141
+ fields={allFields}
142
+ conditions={[]}
143
+ onConditionsChange={onConditionsChange}
144
+ />,
145
+ );
146
+
147
+ fireEvent.click(screen.getByText("Add filter"));
148
+
149
+ expect(container.querySelectorAll('[data-slot="condition-row"]')).toHaveLength(1);
150
+ expect(screen.getByText("Where")).toBeDefined();
151
+ expect(onConditionsChange).toHaveBeenCalledWith([]);
152
+ });
153
+
154
+ it("commits a text draft with Apply after a value is entered", () => {
155
+ render(
156
+ <DataTableConditionFilter
157
+ fields={allFields}
158
+ conditions={[]}
159
+ onConditionsChange={onConditionsChange}
160
+ />,
161
+ );
162
+
163
+ fireEvent.click(screen.getByText("Add filter"));
164
+ fireEvent.change(screen.getByPlaceholderText("Enter value..."), {
165
+ target: { value: "Acme" },
166
+ });
167
+ fireEvent.click(screen.getByText("Apply"));
168
+
169
+ const committed = onConditionsChange.mock.calls.at(-1)?.[0];
170
+ expect(committed).toHaveLength(1);
171
+ expect(committed?.[0]).toMatchObject({ field: "name", operator: "eq", value: "Acme" });
172
+ });
173
+
174
+ it("Enter in the value field commits the current draft row", () => {
175
+ render(
176
+ <DataTableConditionFilter
177
+ fields={allFields}
178
+ conditions={[]}
179
+ onConditionsChange={onConditionsChange}
180
+ />,
181
+ );
182
+
183
+ fireEvent.click(screen.getByText("Add filter"));
184
+ const valueInput = screen.getByPlaceholderText("Enter value...");
185
+ fireEvent.change(valueInput, { target: { value: "Acme" } });
186
+ fireEvent.keyDown(valueInput, { key: "Enter" });
187
+
188
+ const committed = onConditionsChange.mock.calls.at(-1)?.[0];
189
+ expect(committed?.[0]).toMatchObject({ field: "name", operator: "eq", value: "Acme" });
190
+ });
191
+
192
+ it("adding another row commits existing complete drafts and labels rows Where/And", () => {
193
+ render(
194
+ <DataTableConditionFilter
195
+ fields={allFields}
196
+ conditions={[]}
197
+ onConditionsChange={onConditionsChange}
198
+ />,
199
+ );
200
+
201
+ fireEvent.click(screen.getByText("Add filter"));
202
+ fireEvent.change(screen.getByPlaceholderText("Enter value..."), {
203
+ target: { value: "Acme" },
204
+ });
205
+ fireEvent.click(screen.getByText("Add filter"));
206
+
207
+ expect(screen.getByText("Where")).toBeDefined();
208
+ expect(screen.getByText("And")).toBeDefined();
209
+ const committed = onConditionsChange.mock.calls.at(-1)?.[0];
210
+ expect(committed).toHaveLength(1);
211
+ expect(committed?.[0].value).toBe("Acme");
212
+ });
213
+
214
+ it("changing field resets operator and clears value", () => {
215
+ const conditions: ConditionFilterValue[] = [
216
+ { id: "c-1", field: "name", operator: "eq", value: "test" },
217
+ ];
218
+
219
+ const { container } = render(
220
+ <DataTableConditionFilter
221
+ fields={allFields}
222
+ conditions={conditions}
223
+ onConditionsChange={onConditionsChange}
224
+ />,
225
+ );
226
+
227
+ const balanceOption = Array.from(container.querySelectorAll('[data-slot="select-item"]'))
228
+ .find((opt) => opt.textContent?.includes("Account Balance"));
229
+ expect(balanceOption).not.toBeUndefined();
230
+ fireEvent.click(balanceOption!);
231
+ fireEvent.click(screen.getByText("Apply"));
232
+
233
+ // The updated row is incomplete after field change, so Apply omits it.
234
+ expect(onConditionsChange.mock.calls.at(-1)?.[0]).toEqual([]);
235
+ });
236
+
237
+ it("hides value input for unary operators and commits null values", () => {
238
+ const conditions: ConditionFilterValue[] = [
239
+ { id: "c-1", field: "name", operator: "is_null", value: null },
240
+ { id: "c-2", field: "balance", operator: "is_not_null", value: null },
241
+ ];
242
+
243
+ const { container } = render(
244
+ <DataTableConditionFilter
245
+ fields={allFields}
246
+ conditions={conditions}
247
+ onConditionsChange={onConditionsChange}
248
+ />,
249
+ );
250
+
251
+ expect(container.querySelectorAll('[data-slot="condition-row"]')).toHaveLength(2);
252
+ expect(container.querySelectorAll("input")).toHaveLength(0);
253
+ expect(screen.getAllByText("No value needed")).toHaveLength(2);
254
+
255
+ fireEvent.click(screen.getByText("Apply"));
256
+ expect(onConditionsChange.mock.calls.at(-1)?.[0]).toEqual(conditions);
257
+ });
258
+
259
+ it("removing a condition row commits remaining rows", () => {
260
+ const conditions: ConditionFilterValue[] = [
261
+ { id: "c-1", field: "name", operator: "eq", value: "test" },
262
+ { id: "c-2", field: "balance", operator: "gt", value: 100 },
263
+ ];
264
+
265
+ render(
266
+ <DataTableConditionFilter
267
+ fields={allFields}
268
+ conditions={conditions}
269
+ onConditionsChange={onConditionsChange}
270
+ />,
271
+ );
272
+
273
+ fireEvent.click(screen.getAllByRole("button", { name: "Remove condition" })[0]);
274
+
275
+ const updated = onConditionsChange.mock.calls.at(-1)?.[0];
276
+ expect(updated).toHaveLength(1);
277
+ expect(updated?.[0]).toMatchObject({ field: "balance", operator: "gt", value: 100 });
278
+ });
279
+
280
+ it("clears all builder conditions", () => {
281
+ render(
282
+ <DataTableConditionFilter
283
+ fields={allFields}
284
+ conditions={[{ id: "c-1", field: "name", operator: "eq", value: "test" }]}
285
+ onConditionsChange={onConditionsChange}
286
+ />,
287
+ );
288
+
289
+ fireEvent.click(screen.getByText("Clear filters"));
290
+
291
+ expect(onConditionsChange).toHaveBeenCalledWith([]);
292
+ });
293
+
294
+ it("renders currency prefix, parses currency as a number, and renders number inputs", () => {
295
+ const conditions: ConditionFilterValue[] = [
296
+ { id: "c-1", field: "revenue", operator: "eq", value: 5000 },
297
+ ];
298
+
299
+ const { container } = render(
300
+ <DataTableConditionFilter
301
+ fields={allFields}
302
+ conditions={conditions}
303
+ onConditionsChange={onConditionsChange}
304
+ />,
305
+ );
306
+
307
+ expect(screen.getByText("$")).toBeDefined();
308
+ const input = container.querySelector('input[type="number"]');
309
+ expect(input).not.toBeNull();
310
+ fireEvent.change(input!, { target: { value: "6500" } });
311
+ fireEvent.click(screen.getByText("Apply"));
312
+
313
+ expect(onConditionsChange.mock.calls.at(-1)?.[0][0].value).toBe(6500);
314
+ });
315
+
316
+ it("renders and commits date values with a date input", () => {
317
+ const conditions: ConditionFilterValue[] = [
318
+ { id: "c-1", field: "created_at", operator: "gte", value: "2026-01-01" },
319
+ ];
320
+
321
+ const { container } = render(
322
+ <DataTableConditionFilter
323
+ fields={allFields}
324
+ conditions={conditions}
325
+ onConditionsChange={onConditionsChange}
326
+ />,
327
+ );
328
+
329
+ const input = container.querySelector('input[type="date"]');
330
+ expect(input).not.toBeNull();
331
+ fireEvent.change(input!, { target: { value: "2026-02-03" } });
332
+ fireEvent.click(screen.getByText("Apply"));
333
+
334
+ expect(onConditionsChange.mock.calls.at(-1)?.[0][0].value).toBe("2026-02-03");
335
+ });
336
+
337
+ it("renders all provided conditions", () => {
338
+ const conditions: ConditionFilterValue[] = [
339
+ { id: "c-1", field: "name", operator: "eq", value: "Acme" },
340
+ { id: "c-2", field: "balance", operator: "gt", value: 100 },
341
+ { id: "c-3", field: "revenue", operator: "gte", value: 50000 },
342
+ ];
343
+
344
+ const { container } = render(
345
+ <DataTableConditionFilter
346
+ fields={allFields}
347
+ conditions={conditions}
348
+ onConditionsChange={onConditionsChange}
349
+ />,
350
+ );
351
+
352
+ expect(container.querySelectorAll('[data-slot="condition-row"]')).toHaveLength(3);
353
+ expect(screen.getByText("Where")).toBeDefined();
354
+ expect(screen.getAllByText("And")).toHaveLength(2);
355
+ });
356
+
357
+ it("operator options are scalar only and exclude in/contains", () => {
358
+ expect(getOperators(textField)).toEqual(["eq", "neq", "is_null", "is_not_null"]);
359
+ expect(getOperators({ ...textField, operators: ["eq", "in", "is_null"] })).toEqual(["eq", "is_null"]);
360
+
361
+ const { container } = render(
362
+ <DataTableConditionFilter
363
+ fields={allFields}
364
+ conditions={[{ id: "c-1", field: "name", operator: "eq", value: null }]}
365
+ onConditionsChange={onConditionsChange}
366
+ />,
367
+ );
368
+
369
+ const optionTexts = Array.from(container.querySelectorAll('[data-slot="select-item"]'))
370
+ .map((el) => el.textContent);
371
+ expect(optionTexts).not.toContain("contains");
372
+ expect(optionTexts).toContain("is empty");
373
+ });
374
+
375
+ it("operator options change based on field type", () => {
376
+ const { container } = render(
377
+ <DataTableConditionFilter
378
+ fields={allFields}
379
+ conditions={[{ id: "c-1", field: "balance", operator: "eq", value: null }]}
380
+ onConditionsChange={onConditionsChange}
381
+ />,
382
+ );
383
+
384
+ const optionTexts = Array.from(container.querySelectorAll('[data-slot="select-item"]'))
385
+ .map((el) => el.textContent);
386
+ expect(optionTexts).toContain(">");
387
+ expect(optionTexts).toContain("≥");
388
+ expect(optionTexts).toContain("<");
389
+ expect(optionTexts).toContain("≤");
390
+ expect(optionTexts).not.toContain("contains");
391
+ });
392
+
393
+ it("generateConditionId returns unique values", () => {
394
+ const ids = new Set(Array.from({ length: 100 }, () => generateConditionId()));
395
+ expect(ids.size).toBe(100);
396
+ });
397
+ });
@@ -0,0 +1,209 @@
1
+ import { describe, it, expect, vi } from "vitest"
2
+ import React from "react"
3
+ import { render, fireEvent } from "@testing-library/react"
4
+ import { DataTableFilter } from "../data-table-filter"
5
+ import { ListFilter } from "lucide-react"
6
+
7
+ const categories = [
8
+ {
9
+ id: "status",
10
+ label: "Status",
11
+ icon: ListFilter,
12
+ options: [
13
+ { value: "open", label: "Open" },
14
+ { value: "closed", label: "Closed" },
15
+ ],
16
+ },
17
+ {
18
+ id: "type",
19
+ label: "Type",
20
+ icon: ListFilter,
21
+ options: [
22
+ { value: "churn", label: "Churn-Risk" },
23
+ { value: "other", label: "Other" },
24
+ ],
25
+ },
26
+ ]
27
+
28
+ describe("DataTableFilter – preset support", () => {
29
+ it("renders preset chips below trigger button", () => {
30
+ const { container } = render(
31
+ <DataTableFilter
32
+ categories={categories}
33
+ selectedFilters={{ status: ["open"] }}
34
+ onToggleFilter={() => {}}
35
+ presetFilters={{ status: ["open"] }}
36
+ onTogglePreset={() => {}}
37
+ />
38
+ )
39
+
40
+ // Should have a wrapper div with flex-wrap
41
+ const wrapper = container.firstElementChild
42
+ expect(wrapper).not.toBeNull()
43
+ expect(wrapper!.className).toContain("flex")
44
+ expect(wrapper!.className).toContain("flex-wrap")
45
+
46
+ // Should have a preset chip button (not the dropdown trigger)
47
+ const buttons = container.querySelectorAll("button")
48
+ // One trigger button + one preset chip
49
+ expect(buttons.length).toBeGreaterThanOrEqual(2)
50
+ })
51
+
52
+ it("preset chips show 'Default:' prefix label", () => {
53
+ const { getByText } = render(
54
+ <DataTableFilter
55
+ categories={categories}
56
+ selectedFilters={{ status: ["open"] }}
57
+ onToggleFilter={() => {}}
58
+ presetFilters={{ status: ["open"] }}
59
+ onTogglePreset={() => {}}
60
+ />
61
+ )
62
+
63
+ // The chip should contain the label text
64
+ expect(getByText("Open")).not.toBeNull()
65
+ })
66
+
67
+ it("preset chips have no X dismiss button", () => {
68
+ const { container } = render(
69
+ <DataTableFilter
70
+ categories={categories}
71
+ selectedFilters={{ status: ["open"] }}
72
+ onToggleFilter={() => {}}
73
+ presetFilters={{ status: ["open"] }}
74
+ onTogglePreset={() => {}}
75
+ />
76
+ )
77
+
78
+ // No X icon — preset chips should not have a dismiss button with aria-label close or X text
79
+ const allButtons = container.querySelectorAll("button")
80
+ for (const btn of allButtons) {
81
+ const text = btn.textContent ?? ""
82
+ // Preset chip buttons should not have just "X" as text content
83
+ if (text.includes("Open") && text.includes("Default")) {
84
+ // This is the preset chip, should not contain an "×" or "X" dismiss icon child
85
+ const xIcon = btn.querySelector('[data-testid="dismiss"]')
86
+ expect(xIcon).toBeNull()
87
+ }
88
+ }
89
+ })
90
+
91
+ it("clicking preset chip calls onTogglePreset", () => {
92
+ const onTogglePreset = vi.fn()
93
+ const { container } = render(
94
+ <DataTableFilter
95
+ categories={categories}
96
+ selectedFilters={{ status: ["open"] }}
97
+ onToggleFilter={() => {}}
98
+ presetFilters={{ status: ["open"] }}
99
+ onTogglePreset={onTogglePreset}
100
+ />
101
+ )
102
+
103
+ // Find the preset chip button (contains "Open" and "Default")
104
+ const buttons = container.querySelectorAll("button")
105
+ const presetChip = Array.from(buttons).find(
106
+ (btn) => btn.textContent?.includes("Open") && btn.textContent?.includes("Default")
107
+ )
108
+ expect(presetChip).not.toBeUndefined()
109
+
110
+ fireEvent.click(presetChip!)
111
+ expect(onTogglePreset).toHaveBeenCalledWith("status", "open")
112
+ })
113
+
114
+ it("deactivated preset chips show muted/line-through style", () => {
115
+ const { container } = render(
116
+ <DataTableFilter
117
+ categories={categories}
118
+ selectedFilters={{}}
119
+ onToggleFilter={() => {}}
120
+ presetFilters={{ status: ["open"] }}
121
+ onTogglePreset={() => {}}
122
+ />
123
+ )
124
+
125
+ // Find the preset chip
126
+ const buttons = container.querySelectorAll("button")
127
+ const presetChip = Array.from(buttons).find(
128
+ (btn) => btn.textContent?.includes("Open") && btn.textContent?.includes("Default")
129
+ )
130
+ expect(presetChip).not.toBeUndefined()
131
+ expect(presetChip!.className).toContain("line-through")
132
+ expect(presetChip!.className).toContain("text-muted-foreground/60")
133
+ })
134
+
135
+ it("active preset chips show brand-purple style", () => {
136
+ const { container } = render(
137
+ <DataTableFilter
138
+ categories={categories}
139
+ selectedFilters={{ status: ["open"] }}
140
+ onToggleFilter={() => {}}
141
+ presetFilters={{ status: ["open"] }}
142
+ onTogglePreset={() => {}}
143
+ />
144
+ )
145
+
146
+ const buttons = container.querySelectorAll("button")
147
+ const presetChip = Array.from(buttons).find(
148
+ (btn) => btn.textContent?.includes("Open") && btn.textContent?.includes("Default")
149
+ )
150
+ expect(presetChip).not.toBeUndefined()
151
+ expect(presetChip!.className).toContain("border-brand-purple/30")
152
+ expect(presetChip!.className).toContain("bg-brand-purple/5")
153
+ expect(presetChip!.className).not.toContain("line-through")
154
+ })
155
+
156
+ it("uses custom presetLabel", () => {
157
+ const { getByText } = render(
158
+ <DataTableFilter
159
+ categories={categories}
160
+ selectedFilters={{ status: ["open"] }}
161
+ onToggleFilter={() => {}}
162
+ presetFilters={{ status: ["open"] }}
163
+ onTogglePreset={() => {}}
164
+ presetLabel="Preset"
165
+ />
166
+ )
167
+
168
+ // The label prefix should show "Preset: "
169
+ const presetPrefix = getByText(/Preset:/)
170
+ expect(presetPrefix).not.toBeNull()
171
+ })
172
+
173
+ it("renders multiple preset chips for multiple categories", () => {
174
+ const { container } = render(
175
+ <DataTableFilter
176
+ categories={categories}
177
+ selectedFilters={{ status: ["open"], type: ["churn"] }}
178
+ onToggleFilter={() => {}}
179
+ presetFilters={{ status: ["open"], type: ["churn"] }}
180
+ onTogglePreset={() => {}}
181
+ />
182
+ )
183
+
184
+ const buttons = container.querySelectorAll("button")
185
+ const presetChips = Array.from(buttons).filter(
186
+ (btn) => btn.textContent?.includes("Default")
187
+ )
188
+ // Two preset chips (one for status:open, one for type:churn)
189
+ expect(presetChips.length).toBe(2)
190
+ })
191
+
192
+ it("does not render wrapper div when no preset filters", () => {
193
+ const { container } = render(
194
+ <DataTableFilter
195
+ categories={categories}
196
+ selectedFilters={{}}
197
+ onToggleFilter={() => {}}
198
+ />
199
+ )
200
+
201
+ // Without presets, the root should be the DropdownMenu
202
+ // which doesn't have flex-wrap class
203
+ const _wrapper = container.firstElementChild
204
+ // The button should be the first interactive child (DropdownMenu renders button)
205
+ const button = container.querySelector("button")
206
+ expect(button).not.toBeNull()
207
+ expect(button!.textContent).toContain("Filter")
208
+ })
209
+ })