@handled-ai/design-system 0.16.0 → 0.16.2

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 (30) hide show
  1. package/dist/components/data-table-condition-filter.d.ts +37 -0
  2. package/dist/components/data-table-condition-filter.js +424 -0
  3. package/dist/components/data-table-condition-filter.js.map +1 -0
  4. package/dist/components/data-table-filter.d.ts +12 -1
  5. package/dist/components/data-table-filter.js +91 -20
  6. package/dist/components/data-table-filter.js.map +1 -1
  7. package/dist/components/data-table-toolbar.d.ts +1 -0
  8. package/dist/components/data-table.d.ts +1 -0
  9. package/dist/index.d.ts +1 -0
  10. package/dist/index.js +1 -0
  11. package/dist/index.js.map +1 -1
  12. package/dist/prototype/index.d.ts +1 -0
  13. package/dist/prototype/prototype-accounts-view.d.ts +1 -0
  14. package/dist/prototype/prototype-admin-view.d.ts +1 -0
  15. package/dist/prototype/prototype-config.d.ts +1 -0
  16. package/dist/prototype/prototype-inbox-view.d.ts +4 -1
  17. package/dist/prototype/prototype-inbox-view.js +6 -1
  18. package/dist/prototype/prototype-inbox-view.js.map +1 -1
  19. package/dist/prototype/prototype-insights-view.d.ts +1 -0
  20. package/dist/prototype/prototype-shell.d.ts +1 -0
  21. package/package.json +1 -1
  22. package/src/components/__tests__/contact-list.test.tsx +1 -1
  23. package/src/components/__tests__/data-table-condition-filter.test.tsx +423 -0
  24. package/src/components/__tests__/data-table-filter-presets.test.tsx +1 -1
  25. package/src/components/__tests__/data-table-filter.test.tsx +291 -3
  26. package/src/components/data-table-condition-filter.tsx +541 -0
  27. package/src/components/data-table-filter.tsx +101 -19
  28. package/src/index.ts +1 -0
  29. package/src/prototype/__tests__/detail-view-attention.test.tsx +101 -0
  30. package/src/prototype/prototype-inbox-view.tsx +8 -0
@@ -0,0 +1,423 @@
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
+
155
+ it("preserves an in-progress draft when fields are recreated with the same definition", () => {
156
+ const { rerender } = render(
157
+ <DataTableConditionFilter
158
+ fields={[...allFields]}
159
+ conditions={[]}
160
+ onConditionsChange={onConditionsChange}
161
+ />,
162
+ );
163
+
164
+ fireEvent.click(screen.getByText("Add filter"));
165
+ fireEvent.change(screen.getByPlaceholderText("Enter value..."), {
166
+ target: { value: "Acme" },
167
+ });
168
+
169
+ rerender(
170
+ <DataTableConditionFilter
171
+ fields={[...allFields]}
172
+ conditions={[]}
173
+ onConditionsChange={onConditionsChange}
174
+ />,
175
+ );
176
+
177
+ expect((screen.getByPlaceholderText("Enter value...") as HTMLInputElement).value).toBe("Acme");
178
+ });
179
+
180
+ it("commits a text draft with Apply after a value is entered", () => {
181
+ render(
182
+ <DataTableConditionFilter
183
+ fields={allFields}
184
+ conditions={[]}
185
+ onConditionsChange={onConditionsChange}
186
+ />,
187
+ );
188
+
189
+ fireEvent.click(screen.getByText("Add filter"));
190
+ fireEvent.change(screen.getByPlaceholderText("Enter value..."), {
191
+ target: { value: "Acme" },
192
+ });
193
+ fireEvent.click(screen.getByText("Apply"));
194
+
195
+ const committed = onConditionsChange.mock.calls.at(-1)?.[0];
196
+ expect(committed).toHaveLength(1);
197
+ expect(committed?.[0]).toMatchObject({ field: "name", operator: "eq", value: "Acme" });
198
+ });
199
+
200
+ it("Enter in the value field commits the current draft row", () => {
201
+ render(
202
+ <DataTableConditionFilter
203
+ fields={allFields}
204
+ conditions={[]}
205
+ onConditionsChange={onConditionsChange}
206
+ />,
207
+ );
208
+
209
+ fireEvent.click(screen.getByText("Add filter"));
210
+ const valueInput = screen.getByPlaceholderText("Enter value...");
211
+ fireEvent.change(valueInput, { target: { value: "Acme" } });
212
+ fireEvent.keyDown(valueInput, { key: "Enter" });
213
+
214
+ const committed = onConditionsChange.mock.calls.at(-1)?.[0];
215
+ expect(committed?.[0]).toMatchObject({ field: "name", operator: "eq", value: "Acme" });
216
+ });
217
+
218
+ it("adding another row commits existing complete drafts and labels rows Where/And", () => {
219
+ render(
220
+ <DataTableConditionFilter
221
+ fields={allFields}
222
+ conditions={[]}
223
+ onConditionsChange={onConditionsChange}
224
+ />,
225
+ );
226
+
227
+ fireEvent.click(screen.getByText("Add filter"));
228
+ fireEvent.change(screen.getByPlaceholderText("Enter value..."), {
229
+ target: { value: "Acme" },
230
+ });
231
+ fireEvent.click(screen.getByText("Add filter"));
232
+
233
+ expect(screen.getByText("Where")).toBeDefined();
234
+ expect(screen.getByText("And")).toBeDefined();
235
+ const committed = onConditionsChange.mock.calls.at(-1)?.[0];
236
+ expect(committed).toHaveLength(1);
237
+ expect(committed?.[0].value).toBe("Acme");
238
+ });
239
+
240
+ it("changing field resets operator and clears value", () => {
241
+ const conditions: ConditionFilterValue[] = [
242
+ { id: "c-1", field: "name", operator: "eq", value: "test" },
243
+ ];
244
+
245
+ const { container } = render(
246
+ <DataTableConditionFilter
247
+ fields={allFields}
248
+ conditions={conditions}
249
+ onConditionsChange={onConditionsChange}
250
+ />,
251
+ );
252
+
253
+ const balanceOption = Array.from(container.querySelectorAll('[data-slot="select-item"]'))
254
+ .find((opt) => opt.textContent?.includes("Account Balance"));
255
+ expect(balanceOption).not.toBeUndefined();
256
+ fireEvent.click(balanceOption!);
257
+ fireEvent.click(screen.getByText("Apply"));
258
+
259
+ // The updated row is incomplete after field change, so Apply omits it.
260
+ expect(onConditionsChange.mock.calls.at(-1)?.[0]).toEqual([]);
261
+ });
262
+
263
+ it("hides value input for unary operators and commits null values", () => {
264
+ const conditions: ConditionFilterValue[] = [
265
+ { id: "c-1", field: "name", operator: "is_null", value: null },
266
+ { id: "c-2", field: "balance", operator: "is_not_null", value: null },
267
+ ];
268
+
269
+ const { container } = render(
270
+ <DataTableConditionFilter
271
+ fields={allFields}
272
+ conditions={conditions}
273
+ onConditionsChange={onConditionsChange}
274
+ />,
275
+ );
276
+
277
+ expect(container.querySelectorAll('[data-slot="condition-row"]')).toHaveLength(2);
278
+ expect(container.querySelectorAll("input")).toHaveLength(0);
279
+ expect(screen.getAllByText("No value needed")).toHaveLength(2);
280
+
281
+ fireEvent.click(screen.getByText("Apply"));
282
+ expect(onConditionsChange.mock.calls.at(-1)?.[0]).toEqual(conditions);
283
+ });
284
+
285
+ it("removing a condition row commits remaining rows", () => {
286
+ const conditions: ConditionFilterValue[] = [
287
+ { id: "c-1", field: "name", operator: "eq", value: "test" },
288
+ { id: "c-2", field: "balance", operator: "gt", value: 100 },
289
+ ];
290
+
291
+ render(
292
+ <DataTableConditionFilter
293
+ fields={allFields}
294
+ conditions={conditions}
295
+ onConditionsChange={onConditionsChange}
296
+ />,
297
+ );
298
+
299
+ fireEvent.click(screen.getAllByRole("button", { name: "Remove condition" })[0]);
300
+
301
+ const updated = onConditionsChange.mock.calls.at(-1)?.[0];
302
+ expect(updated).toHaveLength(1);
303
+ expect(updated?.[0]).toMatchObject({ field: "balance", operator: "gt", value: 100 });
304
+ });
305
+
306
+ it("clears all builder conditions", () => {
307
+ render(
308
+ <DataTableConditionFilter
309
+ fields={allFields}
310
+ conditions={[{ id: "c-1", field: "name", operator: "eq", value: "test" }]}
311
+ onConditionsChange={onConditionsChange}
312
+ />,
313
+ );
314
+
315
+ fireEvent.click(screen.getByText("Clear filters"));
316
+
317
+ expect(onConditionsChange).toHaveBeenCalledWith([]);
318
+ });
319
+
320
+ it("renders currency prefix, parses currency as a number, and renders number inputs", () => {
321
+ const conditions: ConditionFilterValue[] = [
322
+ { id: "c-1", field: "revenue", operator: "eq", value: 5000 },
323
+ ];
324
+
325
+ const { container } = render(
326
+ <DataTableConditionFilter
327
+ fields={allFields}
328
+ conditions={conditions}
329
+ onConditionsChange={onConditionsChange}
330
+ />,
331
+ );
332
+
333
+ expect(screen.getByText("$")).toBeDefined();
334
+ const input = container.querySelector('input[type="number"]');
335
+ expect(input).not.toBeNull();
336
+ fireEvent.change(input!, { target: { value: "6500" } });
337
+ fireEvent.click(screen.getByText("Apply"));
338
+
339
+ expect(onConditionsChange.mock.calls.at(-1)?.[0][0].value).toBe(6500);
340
+ });
341
+
342
+ it("renders and commits date values with a date input", () => {
343
+ const conditions: ConditionFilterValue[] = [
344
+ { id: "c-1", field: "created_at", operator: "gte", value: "2026-01-01" },
345
+ ];
346
+
347
+ const { container } = render(
348
+ <DataTableConditionFilter
349
+ fields={allFields}
350
+ conditions={conditions}
351
+ onConditionsChange={onConditionsChange}
352
+ />,
353
+ );
354
+
355
+ const input = container.querySelector('input[type="date"]');
356
+ expect(input).not.toBeNull();
357
+ fireEvent.change(input!, { target: { value: "2026-02-03" } });
358
+ fireEvent.click(screen.getByText("Apply"));
359
+
360
+ expect(onConditionsChange.mock.calls.at(-1)?.[0][0].value).toBe("2026-02-03");
361
+ });
362
+
363
+ it("renders all provided conditions", () => {
364
+ const conditions: ConditionFilterValue[] = [
365
+ { id: "c-1", field: "name", operator: "eq", value: "Acme" },
366
+ { id: "c-2", field: "balance", operator: "gt", value: 100 },
367
+ { id: "c-3", field: "revenue", operator: "gte", value: 50000 },
368
+ ];
369
+
370
+ const { container } = render(
371
+ <DataTableConditionFilter
372
+ fields={allFields}
373
+ conditions={conditions}
374
+ onConditionsChange={onConditionsChange}
375
+ />,
376
+ );
377
+
378
+ expect(container.querySelectorAll('[data-slot="condition-row"]')).toHaveLength(3);
379
+ expect(screen.getByText("Where")).toBeDefined();
380
+ expect(screen.getAllByText("And")).toHaveLength(2);
381
+ });
382
+
383
+ it("operator options are scalar only and exclude in/contains", () => {
384
+ expect(getOperators(textField)).toEqual(["eq", "neq", "is_null", "is_not_null"]);
385
+ expect(getOperators({ ...textField, operators: ["eq", "in", "is_null"] })).toEqual(["eq", "is_null"]);
386
+
387
+ const { container } = render(
388
+ <DataTableConditionFilter
389
+ fields={allFields}
390
+ conditions={[{ id: "c-1", field: "name", operator: "eq", value: null }]}
391
+ onConditionsChange={onConditionsChange}
392
+ />,
393
+ );
394
+
395
+ const optionTexts = Array.from(container.querySelectorAll('[data-slot="select-item"]'))
396
+ .map((el) => el.textContent);
397
+ expect(optionTexts).not.toContain("contains");
398
+ expect(optionTexts).toContain("is empty");
399
+ });
400
+
401
+ it("operator options change based on field type", () => {
402
+ const { container } = render(
403
+ <DataTableConditionFilter
404
+ fields={allFields}
405
+ conditions={[{ id: "c-1", field: "balance", operator: "eq", value: null }]}
406
+ onConditionsChange={onConditionsChange}
407
+ />,
408
+ );
409
+
410
+ const optionTexts = Array.from(container.querySelectorAll('[data-slot="select-item"]'))
411
+ .map((el) => el.textContent);
412
+ expect(optionTexts).toContain(">");
413
+ expect(optionTexts).toContain("≥");
414
+ expect(optionTexts).toContain("<");
415
+ expect(optionTexts).toContain("≤");
416
+ expect(optionTexts).not.toContain("contains");
417
+ });
418
+
419
+ it("generateConditionId returns unique values", () => {
420
+ const ids = new Set(Array.from({ length: 100 }, () => generateConditionId()));
421
+ expect(ids.size).toBe(100);
422
+ });
423
+ });
@@ -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 wrapper = container.firstElementChild
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()