@handled-ai/design-system 0.16.0 → 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/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 +12 -1
- package/dist/components/data-table-filter.js +92 -10
- 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/tabs.d.ts +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -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__/contact-list.test.tsx +1 -1
- package/src/components/__tests__/data-table-condition-filter.test.tsx +397 -0
- package/src/components/__tests__/data-table-filter-presets.test.tsx +1 -1
- package/src/components/__tests__/data-table-filter.test.tsx +270 -3
- package/src/components/data-table-condition-filter.tsx +513 -0
- package/src/components/data-table-filter.tsx +102 -4
- package/src/index.ts +1 -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
|
+
});
|
|
@@ -200,7 +200,7 @@ describe("DataTableFilter – preset support", () => {
|
|
|
200
200
|
|
|
201
201
|
// Without presets, the root should be the DropdownMenu
|
|
202
202
|
// which doesn't have flex-wrap class
|
|
203
|
-
const
|
|
203
|
+
const _wrapper = container.firstElementChild
|
|
204
204
|
// The button should be the first interactive child (DropdownMenu renders button)
|
|
205
205
|
const button = container.querySelector("button")
|
|
206
206
|
expect(button).not.toBeNull()
|
|
@@ -1,9 +1,80 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
2
|
import React from "react";
|
|
3
|
-
import { render } from "@testing-library/react";
|
|
4
|
-
import { DataTableFilter } from "../data-table-filter";
|
|
3
|
+
import { render, screen, fireEvent } from "@testing-library/react";
|
|
5
4
|
import { ListFilter } from "lucide-react";
|
|
6
5
|
|
|
6
|
+
import { DataTableFilter } from "../data-table-filter";
|
|
7
|
+
import type { DataTableFilterCategory } from "../data-table-filter";
|
|
8
|
+
import type { ConditionFieldDef } from "../data-table-condition-filter";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Radix DropdownMenu renders content inside a portal which makes it
|
|
12
|
+
* difficult to test in happy-dom. We mock the DropdownMenu primitives
|
|
13
|
+
* to render children inline so we can assert on the component's
|
|
14
|
+
* rendering logic directly.
|
|
15
|
+
*/
|
|
16
|
+
vi.mock("../dropdown-menu", async () => {
|
|
17
|
+
const ReactMod = await import("react");
|
|
18
|
+
|
|
19
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
20
|
+
const DropdownMenu = ({ children, ...props }: any) =>
|
|
21
|
+
ReactMod.createElement("div", { "data-slot": "dropdown-menu", ...props }, children);
|
|
22
|
+
const DropdownMenuTrigger = ({ children, asChild, ...props }: any) => {
|
|
23
|
+
if (asChild && ReactMod.isValidElement(children)) {
|
|
24
|
+
return ReactMod.cloneElement(children, { "data-slot": "dropdown-menu-trigger", ...props } as any);
|
|
25
|
+
}
|
|
26
|
+
return ReactMod.createElement("button", { "data-slot": "dropdown-menu-trigger", ...props }, children);
|
|
27
|
+
};
|
|
28
|
+
const DropdownMenuContent = ({ children, ...props }: any) =>
|
|
29
|
+
ReactMod.createElement("div", { "data-slot": "dropdown-menu-content", ...props }, children);
|
|
30
|
+
const DropdownMenuItem = ({ children, onSelect, className, ...props }: any) =>
|
|
31
|
+
ReactMod.createElement(
|
|
32
|
+
"div",
|
|
33
|
+
{
|
|
34
|
+
"data-slot": "dropdown-menu-item",
|
|
35
|
+
role: "menuitem",
|
|
36
|
+
className,
|
|
37
|
+
onClick: (e: any) => onSelect?.(e),
|
|
38
|
+
...props,
|
|
39
|
+
},
|
|
40
|
+
children
|
|
41
|
+
);
|
|
42
|
+
const DropdownMenuSub = ({ children, ...props }: any) =>
|
|
43
|
+
ReactMod.createElement("div", { "data-slot": "dropdown-menu-sub", ...props }, children);
|
|
44
|
+
const DropdownMenuSubTrigger = ({ children, className, ...props }: any) =>
|
|
45
|
+
ReactMod.createElement("div", { "data-slot": "dropdown-menu-sub-trigger", role: "menuitem", className, ...props }, children);
|
|
46
|
+
const DropdownMenuSubContent = ({ children, ...props }: any) =>
|
|
47
|
+
ReactMod.createElement("div", { "data-slot": "dropdown-menu-sub-content", ...props }, children);
|
|
48
|
+
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
DropdownMenu,
|
|
52
|
+
DropdownMenuTrigger,
|
|
53
|
+
DropdownMenuContent,
|
|
54
|
+
DropdownMenuItem,
|
|
55
|
+
DropdownMenuSub,
|
|
56
|
+
DropdownMenuSubTrigger,
|
|
57
|
+
DropdownMenuSubContent,
|
|
58
|
+
};
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
vi.mock("radix-ui", async (importOriginal) => {
|
|
62
|
+
const actual = await importOriginal<typeof import("radix-ui")>();
|
|
63
|
+
const ReactMod = await import("react");
|
|
64
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
65
|
+
const Root = ({ children }: any) => ReactMod.createElement("div", { "data-slot": "popover-root" }, children);
|
|
66
|
+
const Trigger = ({ children, asChild, ...props }: any) => {
|
|
67
|
+
if (asChild && ReactMod.isValidElement(children)) {
|
|
68
|
+
return ReactMod.cloneElement(children, { "data-slot": "popover-trigger", ...props } as any);
|
|
69
|
+
}
|
|
70
|
+
return ReactMod.createElement("button", { "data-slot": "popover-trigger", ...props }, children);
|
|
71
|
+
};
|
|
72
|
+
const Portal = ({ children }: any) => ReactMod.createElement(ReactMod.Fragment, null, children);
|
|
73
|
+
const Content = ({ children, ...props }: any) => ReactMod.createElement("div", { "data-slot": "popover-content", ...props }, children);
|
|
74
|
+
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
75
|
+
return { ...actual, Popover: { Root, Trigger, Portal, Content } };
|
|
76
|
+
});
|
|
77
|
+
|
|
7
78
|
const defaultProps = {
|
|
8
79
|
categories: [
|
|
9
80
|
{
|
|
@@ -38,4 +109,200 @@ describe("DataTableFilter", () => {
|
|
|
38
109
|
expect(button!.className).toContain("text-primary-foreground");
|
|
39
110
|
expect(button!.className).toContain("h-8");
|
|
40
111
|
});
|
|
112
|
+
|
|
113
|
+
it("renders boolean category as direct toggle row", () => {
|
|
114
|
+
const onToggle = vi.fn();
|
|
115
|
+
const boolCategory: DataTableFilterCategory = {
|
|
116
|
+
id: "archived",
|
|
117
|
+
label: "Archived",
|
|
118
|
+
icon: ListFilter,
|
|
119
|
+
options: [],
|
|
120
|
+
type: "boolean",
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
render(
|
|
124
|
+
<DataTableFilter
|
|
125
|
+
categories={[boolCategory]}
|
|
126
|
+
selectedFilters={{}}
|
|
127
|
+
onToggleFilter={onToggle}
|
|
128
|
+
/>
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
const item = screen.getByText("Archived").closest('[data-slot="dropdown-menu-item"]');
|
|
132
|
+
expect(item).not.toBeNull();
|
|
133
|
+
expect(document.querySelector('[data-slot="dropdown-menu-sub-trigger"]')).toBeNull();
|
|
134
|
+
|
|
135
|
+
fireEvent.click(item!);
|
|
136
|
+
expect(onToggle).toHaveBeenCalledWith("archived", "true");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("shows check icon on active boolean category", () => {
|
|
140
|
+
const boolCategory: DataTableFilterCategory = {
|
|
141
|
+
id: "archived",
|
|
142
|
+
label: "Archived",
|
|
143
|
+
icon: ListFilter,
|
|
144
|
+
options: [],
|
|
145
|
+
type: "boolean",
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
render(
|
|
149
|
+
<DataTableFilter
|
|
150
|
+
categories={[boolCategory]}
|
|
151
|
+
selectedFilters={{ archived: ["true"] }}
|
|
152
|
+
onToggleFilter={() => {}}
|
|
153
|
+
/>
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
const item = screen.getByText("Archived").closest('[data-slot="dropdown-menu-item"]');
|
|
157
|
+
expect(item).not.toBeNull();
|
|
158
|
+
expect(item!.className).toContain("text-brand-purple");
|
|
159
|
+
expect(item!.querySelector("svg.ml-auto")).not.toBeNull();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("renders single-select category with submenu", () => {
|
|
163
|
+
const singleCategory: DataTableFilterCategory = {
|
|
164
|
+
id: "priority",
|
|
165
|
+
label: "Priority",
|
|
166
|
+
icon: ListFilter,
|
|
167
|
+
options: ["High", "Medium", "Low"],
|
|
168
|
+
type: "single",
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
render(
|
|
172
|
+
<DataTableFilter
|
|
173
|
+
categories={[singleCategory]}
|
|
174
|
+
selectedFilters={{}}
|
|
175
|
+
onToggleFilter={() => {}}
|
|
176
|
+
/>
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
const subTrigger = document.querySelector('[data-slot="dropdown-menu-sub-trigger"]');
|
|
180
|
+
expect(subTrigger).not.toBeNull();
|
|
181
|
+
expect(subTrigger!.textContent).toContain("Priority");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("shows filled circle indicator for selected single-select option", () => {
|
|
185
|
+
const singleCategory: DataTableFilterCategory = {
|
|
186
|
+
id: "priority",
|
|
187
|
+
label: "Priority",
|
|
188
|
+
icon: ListFilter,
|
|
189
|
+
options: ["High", "Medium", "Low"],
|
|
190
|
+
type: "single",
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
render(
|
|
194
|
+
<DataTableFilter
|
|
195
|
+
categories={[singleCategory]}
|
|
196
|
+
selectedFilters={{ priority: ["High"] }}
|
|
197
|
+
onToggleFilter={() => {}}
|
|
198
|
+
/>
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
const highItem = screen.getByText("High").closest('[data-slot="dropdown-menu-item"]');
|
|
202
|
+
expect(highItem).not.toBeNull();
|
|
203
|
+
expect(highItem!.querySelector("span.rounded-full")).not.toBeNull();
|
|
204
|
+
expect(highItem!.textContent).not.toContain("Applied");
|
|
205
|
+
|
|
206
|
+
const mediumItem = screen.getByText("Medium").closest('[data-slot="dropdown-menu-item"]');
|
|
207
|
+
expect(mediumItem).not.toBeNull();
|
|
208
|
+
expect(mediumItem!.querySelector("span.rounded-full")).toBeNull();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("boolean category keeps dropdown open on click", () => {
|
|
212
|
+
const onToggle = vi.fn();
|
|
213
|
+
const boolCategory: DataTableFilterCategory = {
|
|
214
|
+
id: "archived",
|
|
215
|
+
label: "Archived",
|
|
216
|
+
icon: ListFilter,
|
|
217
|
+
options: [],
|
|
218
|
+
type: "boolean",
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
render(
|
|
222
|
+
<DataTableFilter
|
|
223
|
+
categories={[boolCategory]}
|
|
224
|
+
selectedFilters={{}}
|
|
225
|
+
onToggleFilter={onToggle}
|
|
226
|
+
/>
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
const item = screen.getByText("Archived").closest('[data-slot="dropdown-menu-item"]');
|
|
230
|
+
expect(item).not.toBeNull();
|
|
231
|
+
|
|
232
|
+
const preventDefaultSpy = vi.fn();
|
|
233
|
+
const mockEvent = new MouseEvent("click", { bubbles: true });
|
|
234
|
+
Object.defineProperty(mockEvent, "preventDefault", { value: preventDefaultSpy });
|
|
235
|
+
item!.dispatchEvent(mockEvent);
|
|
236
|
+
|
|
237
|
+
expect(preventDefaultSpy).toHaveBeenCalled();
|
|
238
|
+
expect(onToggle).toHaveBeenCalledWith("archived", "true");
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("multi-select category still works as before", () => {
|
|
242
|
+
const multiCategory: DataTableFilterCategory = {
|
|
243
|
+
id: "status",
|
|
244
|
+
label: "Status",
|
|
245
|
+
icon: ListFilter,
|
|
246
|
+
options: ["Open", "Closed"],
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
render(
|
|
250
|
+
<DataTableFilter
|
|
251
|
+
categories={[multiCategory]}
|
|
252
|
+
selectedFilters={{ status: ["Open"] }}
|
|
253
|
+
onToggleFilter={() => {}}
|
|
254
|
+
/>
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
const subTrigger = document.querySelector('[data-slot="dropdown-menu-sub-trigger"]');
|
|
258
|
+
expect(subTrigger).not.toBeNull();
|
|
259
|
+
expect(subTrigger!.textContent).toContain("Status");
|
|
260
|
+
|
|
261
|
+
const openItem = screen.getByText("Open").closest('[data-slot="dropdown-menu-item"]');
|
|
262
|
+
expect(openItem).not.toBeNull();
|
|
263
|
+
expect(openItem!.textContent).toContain("Applied");
|
|
264
|
+
expect(openItem!.querySelector("span.rounded-full")).toBeNull();
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("does not expose the condition builder entry point without condition fields", () => {
|
|
268
|
+
render(<DataTableFilter {...defaultProps} />);
|
|
269
|
+
|
|
270
|
+
expect(screen.queryByText("Add filter")).toBeNull();
|
|
271
|
+
expect(document.querySelector('[data-slot="condition-filter"]')).toBeNull();
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("exposes a condition builder popover entry point when condition fields are provided", () => {
|
|
275
|
+
const conditionFields: ConditionFieldDef[] = [
|
|
276
|
+
{ id: "balance", label: "Account Balance", type: "currency" },
|
|
277
|
+
];
|
|
278
|
+
|
|
279
|
+
render(
|
|
280
|
+
<DataTableFilter
|
|
281
|
+
{...defaultProps}
|
|
282
|
+
conditionFields={conditionFields}
|
|
283
|
+
conditionFilters={[
|
|
284
|
+
{ id: "c-1", field: "balance", operator: "gt", value: 1000 },
|
|
285
|
+
]}
|
|
286
|
+
onConditionFiltersChange={() => {}}
|
|
287
|
+
/>
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
const builderEntry = document.querySelector('[data-slot="popover-trigger"]');
|
|
291
|
+
expect(builderEntry).not.toBeNull();
|
|
292
|
+
expect(builderEntry!.textContent).toContain("1");
|
|
293
|
+
expect(document.querySelector('[data-slot="condition-filter"]')).not.toBeNull();
|
|
294
|
+
expect(screen.getByText("Filter builder")).toBeDefined();
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("uses the custom condition builder label when provided", () => {
|
|
298
|
+
render(
|
|
299
|
+
<DataTableFilter
|
|
300
|
+
{...defaultProps}
|
|
301
|
+
conditionFields={[{ id: "name", label: "Name", type: "text" }]}
|
|
302
|
+
conditionBuilderLabel="Advanced filters"
|
|
303
|
+
/>
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
expect(screen.getByText("Advanced filters")).toBeDefined();
|
|
307
|
+
});
|
|
41
308
|
});
|