@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.
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/button.d.ts +1 -1
- package/dist/components/collapsible-section.d.ts +20 -0
- package/dist/components/collapsible-section.js +48 -0
- package/dist/components/collapsible-section.js.map +1 -0
- package/dist/components/contact-list.d.ts +3 -1
- package/dist/components/contact-list.js +20 -3
- package/dist/components/contact-list.js.map +1 -1
- package/dist/components/data-table-condition-filter.d.ts +37 -0
- package/dist/components/data-table-condition-filter.js +407 -0
- package/dist/components/data-table-condition-filter.js.map +1 -0
- package/dist/components/data-table-filter.d.ts +19 -2
- package/dist/components/data-table-filter.js +160 -13
- package/dist/components/data-table-filter.js.map +1 -1
- package/dist/components/data-table-toolbar.d.ts +1 -0
- package/dist/components/data-table.d.ts +1 -0
- package/dist/components/entity-panel.js +1 -1
- package/dist/components/entity-panel.js.map +1 -1
- package/dist/components/tabs.d.ts +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/prototype/index.d.ts +1 -0
- package/dist/prototype/prototype-accounts-view.d.ts +1 -0
- package/dist/prototype/prototype-admin-view.d.ts +1 -0
- package/dist/prototype/prototype-config.d.ts +1 -0
- package/dist/prototype/prototype-inbox-view.d.ts +4 -1
- package/dist/prototype/prototype-inbox-view.js +6 -1
- package/dist/prototype/prototype-inbox-view.js.map +1 -1
- package/dist/prototype/prototype-insights-view.d.ts +1 -0
- package/dist/prototype/prototype-shell.d.ts +1 -0
- package/package.json +1 -1
- package/src/components/__tests__/collapsible-section.test.tsx +143 -0
- package/src/components/__tests__/contact-list.test.tsx +116 -0
- package/src/components/__tests__/data-table-condition-filter.test.tsx +397 -0
- package/src/components/__tests__/data-table-filter-presets.test.tsx +209 -0
- package/src/components/__tests__/data-table-filter.test.tsx +270 -3
- package/src/components/__tests__/entity-metadata-grid.test.tsx +25 -0
- package/src/components/__tests__/virtualized-data-table.test.tsx +0 -1
- package/src/components/collapsible-section.tsx +62 -0
- package/src/components/contact-list.tsx +22 -3
- package/src/components/data-table-condition-filter.tsx +513 -0
- package/src/components/data-table-filter.tsx +201 -13
- package/src/components/entity-panel.tsx +1 -1
- package/src/index.ts +2 -0
- package/src/prototype/__tests__/detail-view-attention.test.tsx +101 -0
- 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
|
+
})
|