@object-ui/fields 3.4.0 → 4.0.3
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/CHANGELOG.md +43 -0
- package/package.json +7 -7
- package/dist/packages/fields/src/__tests__/boolean-checkbox.test.d.ts +0 -0
- package/dist/packages/fields/src/__tests__/cell-renderers.test.d.ts +0 -0
- package/dist/packages/fields/src/__tests__/datetime-cell.test.d.ts +0 -0
- package/dist/packages/fields/src/__tests__/datetime-timezone.test.d.ts +0 -0
- package/dist/packages/fields/src/__tests__/extreme-inputs.test.d.ts +0 -0
- package/dist/packages/fields/src/__tests__/form-variants.test.d.ts +0 -0
- package/dist/packages/fields/src/lookup-depends-on.test.d.ts +0 -1
- package/dist/packages/fields/src/new-widgets.test.d.ts +0 -8
- package/dist/packages/fields/src/record-picker.test.d.ts +0 -1
- package/dist/packages/fields/src/remaining-widgets.test.d.ts +0 -1
- package/dist/packages/fields/src/text-rich-widgets.test.d.ts +0 -1
- package/dist/packages/fields/src/visual-widgets.test.d.ts +0 -1
- package/src/__tests__/boolean-checkbox.test.tsx +0 -74
- package/src/__tests__/cell-renderers.test.tsx +0 -1193
- package/src/__tests__/datetime-cell.test.tsx +0 -46
- package/src/__tests__/datetime-timezone.test.tsx +0 -770
- package/src/__tests__/extreme-inputs.test.tsx +0 -567
- package/src/__tests__/form-variants.test.tsx +0 -306
- package/src/lookup-depends-on.test.tsx +0 -130
- package/src/new-widgets.test.tsx +0 -302
- package/src/record-picker.test.tsx +0 -1703
- package/src/remaining-widgets.test.tsx +0 -211
- package/src/text-rich-widgets.test.tsx +0 -104
- package/src/visual-widgets.test.tsx +0 -97
|
@@ -1,1193 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Cell Renderer Tests
|
|
3
|
-
*
|
|
4
|
-
* Tests for getCellRenderer() select/date/boolean type renderers
|
|
5
|
-
* and formatRelativeDate() edge cases.
|
|
6
|
-
*/
|
|
7
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
8
|
-
import { render, screen } from '@testing-library/react';
|
|
9
|
-
import '@testing-library/jest-dom';
|
|
10
|
-
import React from 'react';
|
|
11
|
-
import {
|
|
12
|
-
getCellRenderer,
|
|
13
|
-
SelectCellRenderer,
|
|
14
|
-
LookupCellRenderer,
|
|
15
|
-
UserCellRenderer,
|
|
16
|
-
TextCellRenderer,
|
|
17
|
-
DateCellRenderer,
|
|
18
|
-
BooleanCellRenderer,
|
|
19
|
-
EmailCellRenderer,
|
|
20
|
-
PhoneCellRenderer,
|
|
21
|
-
PercentCellRenderer,
|
|
22
|
-
humanizeLabel,
|
|
23
|
-
formatDate,
|
|
24
|
-
formatRelativeDate,
|
|
25
|
-
} from '../index';
|
|
26
|
-
|
|
27
|
-
// =========================================================================
|
|
28
|
-
// 1. getCellRenderer type resolution
|
|
29
|
-
// =========================================================================
|
|
30
|
-
describe('getCellRenderer', () => {
|
|
31
|
-
it('should return SelectCellRenderer for select type', () => {
|
|
32
|
-
const renderer = getCellRenderer('select');
|
|
33
|
-
expect(renderer).toBeDefined();
|
|
34
|
-
// Verify it renders a badge
|
|
35
|
-
const { container } = render(
|
|
36
|
-
React.createElement(renderer, {
|
|
37
|
-
value: 'Active',
|
|
38
|
-
field: { name: 'status', type: 'select', options: [{ value: 'Active', label: 'Active', color: 'green' }] } as any,
|
|
39
|
-
})
|
|
40
|
-
);
|
|
41
|
-
expect(container.querySelector('[class*="bg-green"]')).toBeInTheDocument();
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
it('should return SelectCellRenderer for status type', () => {
|
|
45
|
-
const renderer = getCellRenderer('status');
|
|
46
|
-
expect(renderer).toBeDefined();
|
|
47
|
-
// Verify it renders a badge (same as select)
|
|
48
|
-
const { container } = render(
|
|
49
|
-
React.createElement(renderer, {
|
|
50
|
-
value: 'Active',
|
|
51
|
-
field: { name: 'status', type: 'status', options: [{ value: 'Active', label: 'Active', color: 'green' }] } as any,
|
|
52
|
-
})
|
|
53
|
-
);
|
|
54
|
-
expect(container.querySelector('[class*="bg-green"]')).toBeInTheDocument();
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it('should return DateCellRenderer for date type', () => {
|
|
58
|
-
const renderer = getCellRenderer('date');
|
|
59
|
-
expect(renderer).toBeDefined();
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
it('should return BooleanCellRenderer for boolean type', () => {
|
|
63
|
-
const renderer = getCellRenderer('boolean');
|
|
64
|
-
expect(renderer).toBeDefined();
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
it('should fallback to TextCellRenderer for unknown types', () => {
|
|
68
|
-
const renderer = getCellRenderer('unknown-type');
|
|
69
|
-
expect(renderer).toBeDefined();
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
it('should return LookupCellRenderer for lookup type', () => {
|
|
73
|
-
const renderer = getCellRenderer('lookup');
|
|
74
|
-
expect(renderer).toBe(LookupCellRenderer);
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
it('should return LookupCellRenderer for master_detail type', () => {
|
|
78
|
-
const renderer = getCellRenderer('master_detail');
|
|
79
|
-
expect(renderer).toBe(LookupCellRenderer);
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it('should return SelectCellRenderer for status type', () => {
|
|
83
|
-
const renderer = getCellRenderer('status');
|
|
84
|
-
expect(renderer).toBe(SelectCellRenderer);
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
it('should return UserCellRenderer for user type', () => {
|
|
88
|
-
const renderer = getCellRenderer('user');
|
|
89
|
-
expect(renderer).toBe(UserCellRenderer);
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
it('should return UserCellRenderer for owner type', () => {
|
|
93
|
-
const renderer = getCellRenderer('owner');
|
|
94
|
-
expect(renderer).toBe(UserCellRenderer);
|
|
95
|
-
});
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
// =========================================================================
|
|
99
|
-
// 2. TextCellRenderer
|
|
100
|
-
// =========================================================================
|
|
101
|
-
describe('TextCellRenderer', () => {
|
|
102
|
-
it('should render text value', () => {
|
|
103
|
-
render(<TextCellRenderer value="hello" field={{ name: 'title', type: 'text' } as any} />);
|
|
104
|
-
expect(screen.getByText('hello')).toBeInTheDocument();
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
it('should render dash for null', () => {
|
|
108
|
-
render(<TextCellRenderer value={null} field={{ name: 'title', type: 'text' } as any} />);
|
|
109
|
-
expect(screen.getByText('—')).toBeInTheDocument();
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
it('should render dash for undefined', () => {
|
|
113
|
-
render(<TextCellRenderer value={undefined} field={{ name: 'title', type: 'text' } as any} />);
|
|
114
|
-
expect(screen.getByText('—')).toBeInTheDocument();
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
it('should render dash for empty string', () => {
|
|
118
|
-
render(<TextCellRenderer value="" field={{ name: 'title', type: 'text' } as any} />);
|
|
119
|
-
expect(screen.getByText('—')).toBeInTheDocument();
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
it('should render "0" for numeric zero (not dash)', () => {
|
|
123
|
-
render(<TextCellRenderer value={0} field={{ name: 'count', type: 'text' } as any} />);
|
|
124
|
-
expect(screen.getByText('0')).toBeInTheDocument();
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
it('should render "false" for boolean false (not dash)', () => {
|
|
128
|
-
render(<TextCellRenderer value={false} field={{ name: 'flag', type: 'text' } as any} />);
|
|
129
|
-
expect(screen.getByText('false')).toBeInTheDocument();
|
|
130
|
-
});
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
// =========================================================================
|
|
134
|
-
// 3. SelectCellRenderer
|
|
135
|
-
// =========================================================================
|
|
136
|
-
describe('SelectCellRenderer', () => {
|
|
137
|
-
it('should render badge with explicit color from options', () => {
|
|
138
|
-
const { container } = render(
|
|
139
|
-
<SelectCellRenderer
|
|
140
|
-
value="In Progress"
|
|
141
|
-
field={{
|
|
142
|
-
name: 'status',
|
|
143
|
-
type: 'select',
|
|
144
|
-
options: [{ value: 'In Progress', label: 'In Progress', color: 'blue' }],
|
|
145
|
-
} as any}
|
|
146
|
-
/>
|
|
147
|
-
);
|
|
148
|
-
expect(screen.getByText('In Progress')).toBeInTheDocument();
|
|
149
|
-
expect(container.querySelector('[class*="bg-blue-50"]')).toBeInTheDocument();
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
it('should fall back to a deterministic palette color when no color configured and no semantic match', () => {
|
|
153
|
-
// Hash-based fallback: distinct values get distinct colors so badges
|
|
154
|
-
// never all collapse to muted gray. The exact color is deterministic
|
|
155
|
-
// (stable across renders) but value-derived; we just assert one of the
|
|
156
|
-
// palette tones is applied.
|
|
157
|
-
const { container, rerender } = render(
|
|
158
|
-
<SelectCellRenderer
|
|
159
|
-
value="Unknown"
|
|
160
|
-
field={{
|
|
161
|
-
name: 'status',
|
|
162
|
-
type: 'select',
|
|
163
|
-
options: [{ value: 'Unknown', label: 'Unknown' }],
|
|
164
|
-
} as any}
|
|
165
|
-
/>
|
|
166
|
-
);
|
|
167
|
-
expect(screen.getByText('Unknown')).toBeInTheDocument();
|
|
168
|
-
const palette = ['blue', 'green', 'purple', 'orange', 'pink', 'indigo', 'yellow', 'red'];
|
|
169
|
-
const matched = palette.some((c) => container.querySelector(`[class*="bg-${c}-50"]`));
|
|
170
|
-
expect(matched).toBe(true);
|
|
171
|
-
|
|
172
|
-
// Same value → same color across renders (deterministic).
|
|
173
|
-
const firstClass = container.querySelector('[data-slot="badge"], [class*="bg-"][class*="-50"]')?.className;
|
|
174
|
-
rerender(
|
|
175
|
-
<SelectCellRenderer
|
|
176
|
-
value="Unknown"
|
|
177
|
-
field={{
|
|
178
|
-
name: 'status',
|
|
179
|
-
type: 'select',
|
|
180
|
-
options: [{ value: 'Unknown', label: 'Unknown' }],
|
|
181
|
-
} as any}
|
|
182
|
-
/>
|
|
183
|
-
);
|
|
184
|
-
const secondClass = container.querySelector('[data-slot="badge"], [class*="bg-"][class*="-50"]')?.className;
|
|
185
|
-
expect(secondClass).toBe(firstClass);
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
it('should auto-detect priority semantic colors (Critical → red)', () => {
|
|
189
|
-
const { container } = render(
|
|
190
|
-
<SelectCellRenderer
|
|
191
|
-
value="Critical"
|
|
192
|
-
field={{ name: 'priority', type: 'select', options: [] } as any}
|
|
193
|
-
/>
|
|
194
|
-
);
|
|
195
|
-
expect(screen.getByText('Critical')).toBeInTheDocument();
|
|
196
|
-
expect(container.querySelector('[class*="bg-red-50"]')).toBeInTheDocument();
|
|
197
|
-
});
|
|
198
|
-
|
|
199
|
-
it('should auto-detect priority semantic colors (High → orange)', () => {
|
|
200
|
-
const { container } = render(
|
|
201
|
-
<SelectCellRenderer
|
|
202
|
-
value="High"
|
|
203
|
-
field={{ name: 'priority', type: 'select', options: [] } as any}
|
|
204
|
-
/>
|
|
205
|
-
);
|
|
206
|
-
expect(container.querySelector('[class*="bg-orange-50"]')).toBeInTheDocument();
|
|
207
|
-
});
|
|
208
|
-
|
|
209
|
-
it('should auto-detect priority semantic colors (Medium → yellow)', () => {
|
|
210
|
-
const { container } = render(
|
|
211
|
-
<SelectCellRenderer
|
|
212
|
-
value="Medium"
|
|
213
|
-
field={{ name: 'priority', type: 'select', options: [] } as any}
|
|
214
|
-
/>
|
|
215
|
-
);
|
|
216
|
-
expect(container.querySelector('[class*="bg-yellow-50"]')).toBeInTheDocument();
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
it('should auto-detect priority semantic colors (Low → gray)', () => {
|
|
220
|
-
const { container } = render(
|
|
221
|
-
<SelectCellRenderer
|
|
222
|
-
value="Low"
|
|
223
|
-
field={{ name: 'priority', type: 'select', options: [] } as any}
|
|
224
|
-
/>
|
|
225
|
-
);
|
|
226
|
-
expect(container.querySelector('[class*="bg-gray-50"]')).toBeInTheDocument();
|
|
227
|
-
});
|
|
228
|
-
|
|
229
|
-
it('should auto-detect status semantic colors (Paid → green)', () => {
|
|
230
|
-
const { container } = render(
|
|
231
|
-
<SelectCellRenderer
|
|
232
|
-
value="Paid"
|
|
233
|
-
field={{ name: 'status', type: 'select', options: [] } as any}
|
|
234
|
-
/>
|
|
235
|
-
);
|
|
236
|
-
expect(container.querySelector('[class*="bg-green-50"]')).toBeInTheDocument();
|
|
237
|
-
});
|
|
238
|
-
|
|
239
|
-
it('should auto-detect status semantic colors (Pending → yellow)', () => {
|
|
240
|
-
const { container } = render(
|
|
241
|
-
<SelectCellRenderer
|
|
242
|
-
value="Pending"
|
|
243
|
-
field={{ name: 'status', type: 'select', options: [] } as any}
|
|
244
|
-
/>
|
|
245
|
-
);
|
|
246
|
-
expect(container.querySelector('[class*="bg-yellow-50"]')).toBeInTheDocument();
|
|
247
|
-
});
|
|
248
|
-
|
|
249
|
-
it('should auto-detect status semantic colors (Shipped → blue)', () => {
|
|
250
|
-
const { container } = render(
|
|
251
|
-
<SelectCellRenderer
|
|
252
|
-
value="Shipped"
|
|
253
|
-
field={{ name: 'status', type: 'select', options: [] } as any}
|
|
254
|
-
/>
|
|
255
|
-
);
|
|
256
|
-
expect(container.querySelector('[class*="bg-blue-50"]')).toBeInTheDocument();
|
|
257
|
-
});
|
|
258
|
-
|
|
259
|
-
it('should auto-detect status semantic colors (Draft → gray)', () => {
|
|
260
|
-
const { container } = render(
|
|
261
|
-
<SelectCellRenderer
|
|
262
|
-
value="Draft"
|
|
263
|
-
field={{ name: 'status', type: 'select', options: [] } as any}
|
|
264
|
-
/>
|
|
265
|
-
);
|
|
266
|
-
expect(container.querySelector('[class*="bg-gray-50"]')).toBeInTheDocument();
|
|
267
|
-
});
|
|
268
|
-
|
|
269
|
-
it('should auto-detect status semantic colors (Cancelled → red)', () => {
|
|
270
|
-
const { container } = render(
|
|
271
|
-
<SelectCellRenderer
|
|
272
|
-
value="Cancelled"
|
|
273
|
-
field={{ name: 'status', type: 'select', options: [] } as any}
|
|
274
|
-
/>
|
|
275
|
-
);
|
|
276
|
-
expect(container.querySelector('[class*="bg-red-50"]')).toBeInTheDocument();
|
|
277
|
-
});
|
|
278
|
-
|
|
279
|
-
it('should auto-detect status semantic colors (Delivered → purple)', () => {
|
|
280
|
-
const { container } = render(
|
|
281
|
-
<SelectCellRenderer
|
|
282
|
-
value="Delivered"
|
|
283
|
-
field={{ name: 'status', type: 'select', options: [] } as any}
|
|
284
|
-
/>
|
|
285
|
-
);
|
|
286
|
-
expect(container.querySelector('[class*="bg-purple-50"]')).toBeInTheDocument();
|
|
287
|
-
});
|
|
288
|
-
|
|
289
|
-
it('should auto-detect status semantic colors with snake_case (in_progress → blue)', () => {
|
|
290
|
-
const { container } = render(
|
|
291
|
-
<SelectCellRenderer
|
|
292
|
-
value="in_progress"
|
|
293
|
-
field={{ name: 'status', type: 'select', options: [] } as any}
|
|
294
|
-
/>
|
|
295
|
-
);
|
|
296
|
-
expect(container.querySelector('[class*="bg-blue-50"]')).toBeInTheDocument();
|
|
297
|
-
});
|
|
298
|
-
|
|
299
|
-
it('should auto-detect status semantic colors with hyphen (in-progress → blue)', () => {
|
|
300
|
-
const { container } = render(
|
|
301
|
-
<SelectCellRenderer
|
|
302
|
-
value="in-progress"
|
|
303
|
-
field={{ name: 'status', type: 'select', options: [] } as any}
|
|
304
|
-
/>
|
|
305
|
-
);
|
|
306
|
-
expect(container.querySelector('[class*="bg-blue-50"]')).toBeInTheDocument();
|
|
307
|
-
});
|
|
308
|
-
|
|
309
|
-
it('should auto-detect status semantic colors with spaces (in progress → blue)', () => {
|
|
310
|
-
const { container } = render(
|
|
311
|
-
<SelectCellRenderer
|
|
312
|
-
value="in progress"
|
|
313
|
-
field={{ name: 'status', type: 'select', options: [] } as any}
|
|
314
|
-
/>
|
|
315
|
-
);
|
|
316
|
-
expect(container.querySelector('[class*="bg-blue-50"]')).toBeInTheDocument();
|
|
317
|
-
});
|
|
318
|
-
|
|
319
|
-
it('should render dash for null/empty value', () => {
|
|
320
|
-
render(
|
|
321
|
-
<SelectCellRenderer
|
|
322
|
-
value={null}
|
|
323
|
-
field={{ name: 'status', type: 'select' } as any}
|
|
324
|
-
/>
|
|
325
|
-
);
|
|
326
|
-
expect(screen.getByText('—')).toBeInTheDocument();
|
|
327
|
-
});
|
|
328
|
-
|
|
329
|
-
it('should render multiple badges for array values', () => {
|
|
330
|
-
render(
|
|
331
|
-
<SelectCellRenderer
|
|
332
|
-
value={['Draft', 'Active']}
|
|
333
|
-
field={{
|
|
334
|
-
name: 'tags',
|
|
335
|
-
type: 'select',
|
|
336
|
-
options: [
|
|
337
|
-
{ value: 'Draft', label: 'Draft', color: 'yellow' },
|
|
338
|
-
{ value: 'Active', label: 'Active', color: 'green' },
|
|
339
|
-
],
|
|
340
|
-
} as any}
|
|
341
|
-
/>
|
|
342
|
-
);
|
|
343
|
-
expect(screen.getByText('Draft')).toBeInTheDocument();
|
|
344
|
-
expect(screen.getByText('Active')).toBeInTheDocument();
|
|
345
|
-
});
|
|
346
|
-
|
|
347
|
-
it('should prefer explicit option color over auto-detected', () => {
|
|
348
|
-
const { container } = render(
|
|
349
|
-
<SelectCellRenderer
|
|
350
|
-
value="High"
|
|
351
|
-
field={{
|
|
352
|
-
name: 'priority',
|
|
353
|
-
type: 'select',
|
|
354
|
-
options: [{ value: 'High', label: 'High', color: 'purple' }],
|
|
355
|
-
} as any}
|
|
356
|
-
/>
|
|
357
|
-
);
|
|
358
|
-
// Explicit purple should override auto-detected orange
|
|
359
|
-
expect(container.querySelector('[class*="bg-purple-50"]')).toBeInTheDocument();
|
|
360
|
-
});
|
|
361
|
-
});
|
|
362
|
-
|
|
363
|
-
// =========================================================================
|
|
364
|
-
// 3. DateCellRenderer
|
|
365
|
-
// =========================================================================
|
|
366
|
-
describe('DateCellRenderer', () => {
|
|
367
|
-
beforeEach(() => {
|
|
368
|
-
// Fix "now" to a known date: 2026-02-24T12:00:00Z using fake timers
|
|
369
|
-
// so that new Date() in formatRelativeDate returns a predictable value
|
|
370
|
-
vi.useFakeTimers();
|
|
371
|
-
vi.setSystemTime(new Date('2026-02-24T12:00:00Z'));
|
|
372
|
-
});
|
|
373
|
-
|
|
374
|
-
afterEach(() => {
|
|
375
|
-
vi.useRealTimers();
|
|
376
|
-
});
|
|
377
|
-
|
|
378
|
-
it('should render today date as "Today"', () => {
|
|
379
|
-
render(
|
|
380
|
-
<DateCellRenderer
|
|
381
|
-
value="2026-02-24T08:00:00Z"
|
|
382
|
-
field={{ name: 'due_date', type: 'date' } as any}
|
|
383
|
-
/>
|
|
384
|
-
);
|
|
385
|
-
expect(screen.getByText('Today')).toBeInTheDocument();
|
|
386
|
-
});
|
|
387
|
-
|
|
388
|
-
it('should render yesterday date as "Yesterday"', () => {
|
|
389
|
-
render(
|
|
390
|
-
<DateCellRenderer
|
|
391
|
-
value="2026-02-23T08:00:00Z"
|
|
392
|
-
field={{ name: 'due_date', type: 'date' } as any}
|
|
393
|
-
/>
|
|
394
|
-
);
|
|
395
|
-
expect(screen.getByText('Yesterday')).toBeInTheDocument();
|
|
396
|
-
});
|
|
397
|
-
|
|
398
|
-
it('should render recent past date as "Overdue Xd"', () => {
|
|
399
|
-
render(
|
|
400
|
-
<DateCellRenderer
|
|
401
|
-
value="2026-02-20T08:00:00Z"
|
|
402
|
-
field={{ name: 'due_date', type: 'date' } as any}
|
|
403
|
-
/>
|
|
404
|
-
);
|
|
405
|
-
expect(screen.getByText('Overdue 4d')).toBeInTheDocument();
|
|
406
|
-
});
|
|
407
|
-
|
|
408
|
-
it('should render overdue date with red text styling', () => {
|
|
409
|
-
const { container } = render(
|
|
410
|
-
<DateCellRenderer
|
|
411
|
-
value="2026-02-20T08:00:00Z"
|
|
412
|
-
field={{ name: 'due_date', type: 'date' } as any}
|
|
413
|
-
/>
|
|
414
|
-
);
|
|
415
|
-
expect(container.querySelector('.text-red-600')).toBeInTheDocument();
|
|
416
|
-
});
|
|
417
|
-
|
|
418
|
-
it('should show ISO string as tooltip', () => {
|
|
419
|
-
const { container } = render(
|
|
420
|
-
<DateCellRenderer
|
|
421
|
-
value="2026-02-20T08:00:00Z"
|
|
422
|
-
field={{ name: 'due_date', type: 'date' } as any}
|
|
423
|
-
/>
|
|
424
|
-
);
|
|
425
|
-
const span = container.querySelector('span[title]');
|
|
426
|
-
expect(span).toBeInTheDocument();
|
|
427
|
-
expect(span?.getAttribute('title')).toContain('2026-02-20');
|
|
428
|
-
});
|
|
429
|
-
|
|
430
|
-
it('should render dash for null value', () => {
|
|
431
|
-
const { container } = render(
|
|
432
|
-
<DateCellRenderer
|
|
433
|
-
value={null}
|
|
434
|
-
field={{ name: 'due_date', type: 'date' } as any}
|
|
435
|
-
/>
|
|
436
|
-
);
|
|
437
|
-
expect(container.textContent).toBe('—');
|
|
438
|
-
});
|
|
439
|
-
|
|
440
|
-
it('should use explicit format when provided', () => {
|
|
441
|
-
render(
|
|
442
|
-
<DateCellRenderer
|
|
443
|
-
value="2026-01-15T08:00:00Z"
|
|
444
|
-
field={{ name: 'created', type: 'date', format: 'short' } as any}
|
|
445
|
-
/>
|
|
446
|
-
);
|
|
447
|
-
// Short format: "Jan 15, '26"
|
|
448
|
-
expect(screen.getByText("Jan 15, '26")).toBeInTheDocument();
|
|
449
|
-
});
|
|
450
|
-
|
|
451
|
-
it('should not show red styling for future dates', () => {
|
|
452
|
-
const { container } = render(
|
|
453
|
-
<DateCellRenderer
|
|
454
|
-
value="2026-03-01T08:00:00Z"
|
|
455
|
-
field={{ name: 'due_date', type: 'date' } as any}
|
|
456
|
-
/>
|
|
457
|
-
);
|
|
458
|
-
expect(container.querySelector('.text-red-600')).not.toBeInTheDocument();
|
|
459
|
-
});
|
|
460
|
-
});
|
|
461
|
-
|
|
462
|
-
// =========================================================================
|
|
463
|
-
// 4. BooleanCellRenderer
|
|
464
|
-
// =========================================================================
|
|
465
|
-
describe('BooleanCellRenderer', () => {
|
|
466
|
-
it('should render checked checkbox for true', () => {
|
|
467
|
-
render(
|
|
468
|
-
<BooleanCellRenderer
|
|
469
|
-
value={true}
|
|
470
|
-
field={{ name: 'active', type: 'boolean' } as any}
|
|
471
|
-
/>
|
|
472
|
-
);
|
|
473
|
-
const checkbox = screen.getByRole('checkbox');
|
|
474
|
-
expect(checkbox).toHaveAttribute('data-state', 'checked');
|
|
475
|
-
});
|
|
476
|
-
|
|
477
|
-
it('should render unchecked checkbox for false', () => {
|
|
478
|
-
render(
|
|
479
|
-
<BooleanCellRenderer
|
|
480
|
-
value={false}
|
|
481
|
-
field={{ name: 'flagged', type: 'boolean' } as any}
|
|
482
|
-
/>
|
|
483
|
-
);
|
|
484
|
-
const checkbox = screen.getByRole('checkbox');
|
|
485
|
-
expect(checkbox).toHaveAttribute('data-state', 'unchecked');
|
|
486
|
-
});
|
|
487
|
-
|
|
488
|
-
it('should render dash for null', () => {
|
|
489
|
-
render(
|
|
490
|
-
<BooleanCellRenderer
|
|
491
|
-
value={null}
|
|
492
|
-
field={{ name: 'active', type: 'boolean' } as any}
|
|
493
|
-
/>
|
|
494
|
-
);
|
|
495
|
-
expect(screen.getByText('—')).toBeInTheDocument();
|
|
496
|
-
});
|
|
497
|
-
|
|
498
|
-
it('should render dash for undefined', () => {
|
|
499
|
-
render(
|
|
500
|
-
<BooleanCellRenderer
|
|
501
|
-
value={undefined}
|
|
502
|
-
field={{ name: 'active', type: 'boolean' } as any}
|
|
503
|
-
/>
|
|
504
|
-
);
|
|
505
|
-
expect(screen.getByText('—')).toBeInTheDocument();
|
|
506
|
-
});
|
|
507
|
-
|
|
508
|
-
it('should render green circle indicator for is_completed=true', () => {
|
|
509
|
-
const { container } = render(
|
|
510
|
-
<BooleanCellRenderer
|
|
511
|
-
value={true}
|
|
512
|
-
field={{ name: 'is_completed', type: 'boolean' } as any}
|
|
513
|
-
/>
|
|
514
|
-
);
|
|
515
|
-
const indicator = container.querySelector('[data-testid="completion-indicator"]');
|
|
516
|
-
expect(indicator).toBeInTheDocument();
|
|
517
|
-
expect(indicator).toHaveClass('bg-green-500');
|
|
518
|
-
});
|
|
519
|
-
|
|
520
|
-
it('should render empty circle indicator for is_completed=false', () => {
|
|
521
|
-
const { container } = render(
|
|
522
|
-
<BooleanCellRenderer
|
|
523
|
-
value={false}
|
|
524
|
-
field={{ name: 'is_completed', type: 'boolean' } as any}
|
|
525
|
-
/>
|
|
526
|
-
);
|
|
527
|
-
const indicator = container.querySelector('[data-testid="completion-indicator"]');
|
|
528
|
-
expect(indicator).toBeInTheDocument();
|
|
529
|
-
expect(indicator).toHaveClass('border-2');
|
|
530
|
-
});
|
|
531
|
-
|
|
532
|
-
it('should render green circle indicator for completed=true', () => {
|
|
533
|
-
const { container } = render(
|
|
534
|
-
<BooleanCellRenderer
|
|
535
|
-
value={true}
|
|
536
|
-
field={{ name: 'completed', type: 'boolean' } as any}
|
|
537
|
-
/>
|
|
538
|
-
);
|
|
539
|
-
const indicator = container.querySelector('[data-testid="completion-indicator"]');
|
|
540
|
-
expect(indicator).toBeInTheDocument();
|
|
541
|
-
expect(indicator).toHaveClass('bg-green-500');
|
|
542
|
-
});
|
|
543
|
-
|
|
544
|
-
it('should render standard checkbox for non-completion boolean fields', () => {
|
|
545
|
-
render(
|
|
546
|
-
<BooleanCellRenderer
|
|
547
|
-
value={true}
|
|
548
|
-
field={{ name: 'active', type: 'boolean' } as any}
|
|
549
|
-
/>
|
|
550
|
-
);
|
|
551
|
-
const checkbox = screen.getByRole('checkbox');
|
|
552
|
-
expect(checkbox).toHaveAttribute('data-state', 'checked');
|
|
553
|
-
});
|
|
554
|
-
});
|
|
555
|
-
|
|
556
|
-
// =========================================================================
|
|
557
|
-
// 4b. BooleanCellRenderer — Warning badge for status fields
|
|
558
|
-
// =========================================================================
|
|
559
|
-
describe('BooleanCellRenderer warning badge', () => {
|
|
560
|
-
it('should render warning badge for active=false', () => {
|
|
561
|
-
const { container } = render(
|
|
562
|
-
<BooleanCellRenderer
|
|
563
|
-
value={false}
|
|
564
|
-
field={{ name: 'active', type: 'boolean', label: 'Active' } as any}
|
|
565
|
-
/>
|
|
566
|
-
);
|
|
567
|
-
const badge = container.querySelector('[data-testid="boolean-warning-badge"]');
|
|
568
|
-
expect(badge).toBeInTheDocument();
|
|
569
|
-
expect(badge?.textContent).toContain('Off');
|
|
570
|
-
});
|
|
571
|
-
|
|
572
|
-
it('should render warning badge for is_enabled=false', () => {
|
|
573
|
-
const { container } = render(
|
|
574
|
-
<BooleanCellRenderer
|
|
575
|
-
value={false}
|
|
576
|
-
field={{ name: 'is_enabled', type: 'boolean', label: 'Enabled' } as any}
|
|
577
|
-
/>
|
|
578
|
-
);
|
|
579
|
-
const badge = container.querySelector('[data-testid="boolean-warning-badge"]');
|
|
580
|
-
expect(badge).toBeInTheDocument();
|
|
581
|
-
});
|
|
582
|
-
|
|
583
|
-
it('should render normal checkbox for active=true', () => {
|
|
584
|
-
render(
|
|
585
|
-
<BooleanCellRenderer
|
|
586
|
-
value={true}
|
|
587
|
-
field={{ name: 'active', type: 'boolean' } as any}
|
|
588
|
-
/>
|
|
589
|
-
);
|
|
590
|
-
const checkbox = screen.getByRole('checkbox');
|
|
591
|
-
expect(checkbox).toHaveAttribute('data-state', 'checked');
|
|
592
|
-
});
|
|
593
|
-
});
|
|
594
|
-
|
|
595
|
-
// =========================================================================
|
|
596
|
-
// 4c. EmailCellRenderer — mailto + copy button
|
|
597
|
-
// =========================================================================
|
|
598
|
-
describe('EmailCellRenderer', () => {
|
|
599
|
-
it('should render mailto link', () => {
|
|
600
|
-
render(<EmailCellRenderer value="test@example.com" field={{ name: 'email', type: 'email' } as any} />);
|
|
601
|
-
const link = screen.getByRole('link');
|
|
602
|
-
expect(link).toHaveAttribute('href', 'mailto:test@example.com');
|
|
603
|
-
expect(screen.getByText('test@example.com')).toBeInTheDocument();
|
|
604
|
-
});
|
|
605
|
-
|
|
606
|
-
it('should render copy button', () => {
|
|
607
|
-
render(<EmailCellRenderer value="test@example.com" field={{ name: 'email', type: 'email' } as any} />);
|
|
608
|
-
const copyBtn = screen.getByLabelText('Copy email');
|
|
609
|
-
expect(copyBtn).toBeInTheDocument();
|
|
610
|
-
});
|
|
611
|
-
|
|
612
|
-
it('should render dash for empty value', () => {
|
|
613
|
-
render(<EmailCellRenderer value={null} field={{ name: 'email', type: 'email' } as any} />);
|
|
614
|
-
expect(screen.getByText('—')).toBeInTheDocument();
|
|
615
|
-
});
|
|
616
|
-
});
|
|
617
|
-
|
|
618
|
-
// =========================================================================
|
|
619
|
-
// 4d. PhoneCellRenderer — tel link + call icon + copy
|
|
620
|
-
// =========================================================================
|
|
621
|
-
describe('PhoneCellRenderer', () => {
|
|
622
|
-
it('should render tel link with phone icon', () => {
|
|
623
|
-
render(<PhoneCellRenderer value="+1-555-1234" field={{ name: 'phone', type: 'phone' } as any} />);
|
|
624
|
-
const link = screen.getByRole('link');
|
|
625
|
-
expect(link).toHaveAttribute('href', 'tel:+1-555-1234');
|
|
626
|
-
expect(screen.getByText('+1-555-1234')).toBeInTheDocument();
|
|
627
|
-
});
|
|
628
|
-
|
|
629
|
-
it('should render copy button', () => {
|
|
630
|
-
render(<PhoneCellRenderer value="+1-555-1234" field={{ name: 'phone', type: 'phone' } as any} />);
|
|
631
|
-
const copyBtn = screen.getByLabelText('Copy phone number');
|
|
632
|
-
expect(copyBtn).toBeInTheDocument();
|
|
633
|
-
});
|
|
634
|
-
|
|
635
|
-
it('should render dash for empty value', () => {
|
|
636
|
-
render(<PhoneCellRenderer value={null} field={{ name: 'phone', type: 'phone' } as any} />);
|
|
637
|
-
expect(screen.getByText('—')).toBeInTheDocument();
|
|
638
|
-
});
|
|
639
|
-
});
|
|
640
|
-
|
|
641
|
-
// =========================================================================
|
|
642
|
-
// 5. formatRelativeDate edge cases
|
|
643
|
-
// =========================================================================
|
|
644
|
-
describe('formatRelativeDate', () => {
|
|
645
|
-
let nowSpy: ReturnType<typeof vi.spyOn>;
|
|
646
|
-
|
|
647
|
-
beforeEach(() => {
|
|
648
|
-
nowSpy = vi.spyOn(Date, 'now').mockReturnValue(new Date('2026-02-24T12:00:00Z').getTime());
|
|
649
|
-
});
|
|
650
|
-
|
|
651
|
-
afterEach(() => {
|
|
652
|
-
nowSpy.mockRestore();
|
|
653
|
-
});
|
|
654
|
-
|
|
655
|
-
it('should return "-" for null', () => {
|
|
656
|
-
expect(formatRelativeDate(null as any)).toBe('—');
|
|
657
|
-
});
|
|
658
|
-
|
|
659
|
-
it('should return "-" for empty string', () => {
|
|
660
|
-
expect(formatRelativeDate('')).toBe('—');
|
|
661
|
-
});
|
|
662
|
-
|
|
663
|
-
it('should return "-" for invalid date', () => {
|
|
664
|
-
expect(formatRelativeDate('not-a-date')).toBe('—');
|
|
665
|
-
});
|
|
666
|
-
|
|
667
|
-
it('should return "Today" for today', () => {
|
|
668
|
-
const today = new Date();
|
|
669
|
-
expect(formatRelativeDate(today)).toBe('Today');
|
|
670
|
-
});
|
|
671
|
-
|
|
672
|
-
it('should return "Yesterday" for yesterday', () => {
|
|
673
|
-
const yesterday = new Date();
|
|
674
|
-
yesterday.setDate(yesterday.getDate() - 1);
|
|
675
|
-
expect(formatRelativeDate(yesterday)).toBe('Yesterday');
|
|
676
|
-
});
|
|
677
|
-
|
|
678
|
-
it('should return "Tomorrow" for tomorrow', () => {
|
|
679
|
-
const tomorrow = new Date();
|
|
680
|
-
tomorrow.setDate(tomorrow.getDate() + 1);
|
|
681
|
-
expect(formatRelativeDate(tomorrow)).toBe('Tomorrow');
|
|
682
|
-
});
|
|
683
|
-
|
|
684
|
-
it('should return "Overdue Xd" for 2-7 days ago', () => {
|
|
685
|
-
const threeDaysAgo = new Date();
|
|
686
|
-
threeDaysAgo.setDate(threeDaysAgo.getDate() - 3);
|
|
687
|
-
expect(formatRelativeDate(threeDaysAgo)).toBe('Overdue 3d');
|
|
688
|
-
});
|
|
689
|
-
|
|
690
|
-
it('should return formatted date for >7 days ago', () => {
|
|
691
|
-
const tenDaysAgo = new Date();
|
|
692
|
-
tenDaysAgo.setDate(tenDaysAgo.getDate() - 10);
|
|
693
|
-
const result = formatRelativeDate(tenDaysAgo);
|
|
694
|
-
const expected = tenDaysAgo.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
|
|
695
|
-
expect(result).toBe(expected);
|
|
696
|
-
});
|
|
697
|
-
|
|
698
|
-
it('should return "In X days" for 2-7 days in the future', () => {
|
|
699
|
-
const fourDaysFromNow = new Date();
|
|
700
|
-
fourDaysFromNow.setDate(fourDaysFromNow.getDate() + 4);
|
|
701
|
-
expect(formatRelativeDate(fourDaysFromNow)).toBe('In 4 days');
|
|
702
|
-
});
|
|
703
|
-
|
|
704
|
-
it('should return formatted date for >7 days in the future', () => {
|
|
705
|
-
const fifteenDaysFromNow = new Date();
|
|
706
|
-
fifteenDaysFromNow.setDate(fifteenDaysFromNow.getDate() + 15);
|
|
707
|
-
const result = formatRelativeDate(fifteenDaysFromNow);
|
|
708
|
-
const expected = fifteenDaysFromNow.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
|
|
709
|
-
expect(result).toBe(expected);
|
|
710
|
-
});
|
|
711
|
-
});
|
|
712
|
-
|
|
713
|
-
// =========================================================================
|
|
714
|
-
// 6. formatDate with relative style
|
|
715
|
-
// =========================================================================
|
|
716
|
-
describe('formatDate', () => {
|
|
717
|
-
it('should return "-" for null', () => {
|
|
718
|
-
expect(formatDate(null as any)).toBe('—');
|
|
719
|
-
});
|
|
720
|
-
|
|
721
|
-
it('should return "-" for invalid date', () => {
|
|
722
|
-
expect(formatDate('invalid')).toBe('—');
|
|
723
|
-
});
|
|
724
|
-
|
|
725
|
-
it('should format as relative when style is "relative"', () => {
|
|
726
|
-
// Just ensure it delegates to formatRelativeDate
|
|
727
|
-
const result = formatDate(new Date(), 'relative');
|
|
728
|
-
expect(result).toBe('Today');
|
|
729
|
-
});
|
|
730
|
-
|
|
731
|
-
it('should format in short style', () => {
|
|
732
|
-
const result = formatDate('2026-01-15T08:00:00Z', 'short');
|
|
733
|
-
expect(result).toBe("Jan 15, '26");
|
|
734
|
-
});
|
|
735
|
-
|
|
736
|
-
it('should format in default style (MMM DD, YYYY)', () => {
|
|
737
|
-
const result = formatDate('2026-01-15T08:00:00Z');
|
|
738
|
-
expect(result).toContain('Jan');
|
|
739
|
-
expect(result).toContain('15');
|
|
740
|
-
expect(result).toContain('2026');
|
|
741
|
-
});
|
|
742
|
-
});
|
|
743
|
-
|
|
744
|
-
// =========================================================================
|
|
745
|
-
// 7. LookupCellRenderer
|
|
746
|
-
// =========================================================================
|
|
747
|
-
describe('LookupCellRenderer', () => {
|
|
748
|
-
it('should render dash for null value', () => {
|
|
749
|
-
render(
|
|
750
|
-
<LookupCellRenderer
|
|
751
|
-
value={null}
|
|
752
|
-
field={{ name: 'customer', type: 'lookup' } as any}
|
|
753
|
-
/>
|
|
754
|
-
);
|
|
755
|
-
expect(screen.getByText('—')).toBeInTheDocument();
|
|
756
|
-
});
|
|
757
|
-
|
|
758
|
-
it('should render dash for empty string', () => {
|
|
759
|
-
render(
|
|
760
|
-
<LookupCellRenderer
|
|
761
|
-
value=""
|
|
762
|
-
field={{ name: 'customer', type: 'lookup' } as any}
|
|
763
|
-
/>
|
|
764
|
-
);
|
|
765
|
-
expect(screen.getByText('—')).toBeInTheDocument();
|
|
766
|
-
});
|
|
767
|
-
|
|
768
|
-
it('should resolve primitive ID to label via field options', () => {
|
|
769
|
-
render(
|
|
770
|
-
<LookupCellRenderer
|
|
771
|
-
value={2}
|
|
772
|
-
field={{
|
|
773
|
-
name: 'customer',
|
|
774
|
-
type: 'lookup',
|
|
775
|
-
options: [
|
|
776
|
-
{ value: 1, label: 'Alice' },
|
|
777
|
-
{ value: 2, label: 'Bob' },
|
|
778
|
-
],
|
|
779
|
-
} as any}
|
|
780
|
-
/>
|
|
781
|
-
);
|
|
782
|
-
expect(screen.getByText('Bob')).toBeInTheDocument();
|
|
783
|
-
});
|
|
784
|
-
|
|
785
|
-
it('should resolve string ID to label via field options', () => {
|
|
786
|
-
render(
|
|
787
|
-
<LookupCellRenderer
|
|
788
|
-
value="contact_1"
|
|
789
|
-
field={{
|
|
790
|
-
name: 'customer',
|
|
791
|
-
type: 'lookup',
|
|
792
|
-
options: [{ value: 'contact_1', label: 'Alice Smith' }],
|
|
793
|
-
} as any}
|
|
794
|
-
/>
|
|
795
|
-
);
|
|
796
|
-
expect(screen.getByText('Alice Smith')).toBeInTheDocument();
|
|
797
|
-
});
|
|
798
|
-
|
|
799
|
-
it('should render raw primitive when no options available', () => {
|
|
800
|
-
render(
|
|
801
|
-
<LookupCellRenderer
|
|
802
|
-
value={42}
|
|
803
|
-
field={{ name: 'customer', type: 'lookup' } as any}
|
|
804
|
-
/>
|
|
805
|
-
);
|
|
806
|
-
expect(screen.getByText('42')).toBeInTheDocument();
|
|
807
|
-
});
|
|
808
|
-
|
|
809
|
-
it('should render object name when value is an object', () => {
|
|
810
|
-
render(
|
|
811
|
-
<LookupCellRenderer
|
|
812
|
-
value={{ name: 'Acme Corp', id: '123' }}
|
|
813
|
-
field={{ name: 'account', type: 'lookup' } as any}
|
|
814
|
-
/>
|
|
815
|
-
);
|
|
816
|
-
expect(screen.getByText('Acme Corp')).toBeInTheDocument();
|
|
817
|
-
});
|
|
818
|
-
|
|
819
|
-
it('should render object label when name is missing', () => {
|
|
820
|
-
render(
|
|
821
|
-
<LookupCellRenderer
|
|
822
|
-
value={{ label: 'Widget Co', id: '456' }}
|
|
823
|
-
field={{ name: 'account', type: 'lookup' } as any}
|
|
824
|
-
/>
|
|
825
|
-
);
|
|
826
|
-
expect(screen.getByText('Widget Co')).toBeInTheDocument();
|
|
827
|
-
});
|
|
828
|
-
|
|
829
|
-
it('should render object id as fallback', () => {
|
|
830
|
-
render(
|
|
831
|
-
<LookupCellRenderer
|
|
832
|
-
value={{ id: '789' }}
|
|
833
|
-
field={{ name: 'account', type: 'lookup' } as any}
|
|
834
|
-
/>
|
|
835
|
-
);
|
|
836
|
-
expect(screen.getByText('789')).toBeInTheDocument();
|
|
837
|
-
});
|
|
838
|
-
|
|
839
|
-
it('should render tags for array of objects', () => {
|
|
840
|
-
render(
|
|
841
|
-
<LookupCellRenderer
|
|
842
|
-
value={[{ name: 'Alice' }, { name: 'Bob' }]}
|
|
843
|
-
field={{ name: 'contacts', type: 'lookup' } as any}
|
|
844
|
-
/>
|
|
845
|
-
);
|
|
846
|
-
expect(screen.getByText('Alice')).toBeInTheDocument();
|
|
847
|
-
expect(screen.getByText('Bob')).toBeInTheDocument();
|
|
848
|
-
});
|
|
849
|
-
|
|
850
|
-
it('should resolve array of primitive IDs via options', () => {
|
|
851
|
-
render(
|
|
852
|
-
<LookupCellRenderer
|
|
853
|
-
value={[1, 3]}
|
|
854
|
-
field={{
|
|
855
|
-
name: 'contacts',
|
|
856
|
-
type: 'lookup',
|
|
857
|
-
options: [
|
|
858
|
-
{ value: 1, label: 'Alice' },
|
|
859
|
-
{ value: 2, label: 'Bob' },
|
|
860
|
-
{ value: 3, label: 'Charlie' },
|
|
861
|
-
],
|
|
862
|
-
} as any}
|
|
863
|
-
/>
|
|
864
|
-
);
|
|
865
|
-
expect(screen.getByText('Alice')).toBeInTheDocument();
|
|
866
|
-
expect(screen.getByText('Charlie')).toBeInTheDocument();
|
|
867
|
-
expect(screen.queryByText('Bob')).not.toBeInTheDocument();
|
|
868
|
-
});
|
|
869
|
-
});
|
|
870
|
-
|
|
871
|
-
// =========================================================================
|
|
872
|
-
// 8. UserCellRenderer
|
|
873
|
-
// =========================================================================
|
|
874
|
-
describe('UserCellRenderer', () => {
|
|
875
|
-
it('should render dash for null value', () => {
|
|
876
|
-
render(
|
|
877
|
-
<UserCellRenderer
|
|
878
|
-
value={null}
|
|
879
|
-
field={{ name: 'owner', type: 'user' } as any}
|
|
880
|
-
/>
|
|
881
|
-
);
|
|
882
|
-
expect(screen.getByText('—')).toBeInTheDocument();
|
|
883
|
-
});
|
|
884
|
-
|
|
885
|
-
it('should render text for primitive user ID (number)', () => {
|
|
886
|
-
render(
|
|
887
|
-
<UserCellRenderer
|
|
888
|
-
value={5}
|
|
889
|
-
field={{ name: 'owner', type: 'user' } as any}
|
|
890
|
-
/>
|
|
891
|
-
);
|
|
892
|
-
expect(screen.getByText('5')).toBeInTheDocument();
|
|
893
|
-
});
|
|
894
|
-
|
|
895
|
-
it('should render text for primitive user ID (string)', () => {
|
|
896
|
-
render(
|
|
897
|
-
<UserCellRenderer
|
|
898
|
-
value="user_abc"
|
|
899
|
-
field={{ name: 'owner', type: 'user' } as any}
|
|
900
|
-
/>
|
|
901
|
-
);
|
|
902
|
-
expect(screen.getByText('user_abc')).toBeInTheDocument();
|
|
903
|
-
});
|
|
904
|
-
|
|
905
|
-
it('should render avatar and name for object value', () => {
|
|
906
|
-
render(
|
|
907
|
-
<UserCellRenderer
|
|
908
|
-
value={{ name: 'John Doe', username: 'jdoe' }}
|
|
909
|
-
field={{ name: 'owner', type: 'user' } as any}
|
|
910
|
-
/>
|
|
911
|
-
);
|
|
912
|
-
expect(screen.getByText('John Doe')).toBeInTheDocument();
|
|
913
|
-
});
|
|
914
|
-
|
|
915
|
-
it('should use username when name is missing', () => {
|
|
916
|
-
render(
|
|
917
|
-
<UserCellRenderer
|
|
918
|
-
value={{ username: 'jdoe' }}
|
|
919
|
-
field={{ name: 'owner', type: 'user' } as any}
|
|
920
|
-
/>
|
|
921
|
-
);
|
|
922
|
-
expect(screen.getByText('jdoe')).toBeInTheDocument();
|
|
923
|
-
});
|
|
924
|
-
|
|
925
|
-
it('should render multiple avatars for array of user objects', () => {
|
|
926
|
-
render(
|
|
927
|
-
<UserCellRenderer
|
|
928
|
-
value={[{ name: 'Alice' }, { name: 'Bob' }]}
|
|
929
|
-
field={{ name: 'assignees', type: 'user' } as any}
|
|
930
|
-
/>
|
|
931
|
-
);
|
|
932
|
-
// Avatar fallbacks contain initials
|
|
933
|
-
expect(screen.getByTitle('Alice')).toBeInTheDocument();
|
|
934
|
-
expect(screen.getByTitle('Bob')).toBeInTheDocument();
|
|
935
|
-
});
|
|
936
|
-
});
|
|
937
|
-
|
|
938
|
-
// =========================================================================
|
|
939
|
-
// 9. humanizeLabel
|
|
940
|
-
// =========================================================================
|
|
941
|
-
|
|
942
|
-
describe('humanizeLabel', () => {
|
|
943
|
-
it('should convert snake_case to Title Case', () => {
|
|
944
|
-
expect(humanizeLabel('in_progress')).toBe('In Progress');
|
|
945
|
-
});
|
|
946
|
-
|
|
947
|
-
it('should convert kebab-case to Title Case', () => {
|
|
948
|
-
expect(humanizeLabel('high-priority')).toBe('High Priority');
|
|
949
|
-
});
|
|
950
|
-
|
|
951
|
-
it('should handle single word', () => {
|
|
952
|
-
expect(humanizeLabel('active')).toBe('Active');
|
|
953
|
-
});
|
|
954
|
-
|
|
955
|
-
it('should handle already Title Case', () => {
|
|
956
|
-
expect(humanizeLabel('Active')).toBe('Active');
|
|
957
|
-
});
|
|
958
|
-
|
|
959
|
-
it('should handle multiple underscores', () => {
|
|
960
|
-
expect(humanizeLabel('not_yet_started')).toBe('Not Yet Started');
|
|
961
|
-
});
|
|
962
|
-
|
|
963
|
-
it('should handle empty string', () => {
|
|
964
|
-
expect(humanizeLabel('')).toBe('');
|
|
965
|
-
});
|
|
966
|
-
|
|
967
|
-
it('should handle mixed separators', () => {
|
|
968
|
-
expect(humanizeLabel('in_progress-now')).toBe('In Progress Now');
|
|
969
|
-
});
|
|
970
|
-
});
|
|
971
|
-
|
|
972
|
-
// =========================================================================
|
|
973
|
-
// 10. SelectCellRenderer humanizeLabel fallback
|
|
974
|
-
// =========================================================================
|
|
975
|
-
describe('SelectCellRenderer humanizeLabel fallback', () => {
|
|
976
|
-
it('should humanize snake_case value when no option label exists', () => {
|
|
977
|
-
render(
|
|
978
|
-
<SelectCellRenderer
|
|
979
|
-
value="in_progress"
|
|
980
|
-
field={{ name: 'status', type: 'select', options: [] } as any}
|
|
981
|
-
/>
|
|
982
|
-
);
|
|
983
|
-
expect(screen.getByText('In Progress')).toBeInTheDocument();
|
|
984
|
-
});
|
|
985
|
-
|
|
986
|
-
it('should prefer explicit option.label over humanized fallback', () => {
|
|
987
|
-
render(
|
|
988
|
-
<SelectCellRenderer
|
|
989
|
-
value="in_progress"
|
|
990
|
-
field={{
|
|
991
|
-
name: 'status',
|
|
992
|
-
type: 'select',
|
|
993
|
-
options: [{ value: 'in_progress', label: 'WIP' }],
|
|
994
|
-
} as any}
|
|
995
|
-
/>
|
|
996
|
-
);
|
|
997
|
-
expect(screen.getByText('WIP')).toBeInTheDocument();
|
|
998
|
-
expect(screen.queryByText('In Progress')).not.toBeInTheDocument();
|
|
999
|
-
});
|
|
1000
|
-
|
|
1001
|
-
it('should humanize snake_case values in arrays', () => {
|
|
1002
|
-
render(
|
|
1003
|
-
<SelectCellRenderer
|
|
1004
|
-
value={['not_started', 'in_progress']}
|
|
1005
|
-
field={{ name: 'status', type: 'select', options: [] } as any}
|
|
1006
|
-
/>
|
|
1007
|
-
);
|
|
1008
|
-
expect(screen.getByText('Not Started')).toBeInTheDocument();
|
|
1009
|
-
expect(screen.getByText('In Progress')).toBeInTheDocument();
|
|
1010
|
-
});
|
|
1011
|
-
});
|
|
1012
|
-
|
|
1013
|
-
// =========================================================================
|
|
1014
|
-
// 11. PercentCellRenderer with progress-type fields
|
|
1015
|
-
// =========================================================================
|
|
1016
|
-
describe('PercentCellRenderer progress-type fields', () => {
|
|
1017
|
-
it('should render progress field value 75 as 75% with correct bar', () => {
|
|
1018
|
-
const { container } = render(
|
|
1019
|
-
<PercentCellRenderer
|
|
1020
|
-
value={75}
|
|
1021
|
-
field={{ name: 'progress', type: 'percent' } as any}
|
|
1022
|
-
/>
|
|
1023
|
-
);
|
|
1024
|
-
expect(screen.getByText('75%')).toBeInTheDocument();
|
|
1025
|
-
const bar = container.querySelector('[role="progressbar"]');
|
|
1026
|
-
expect(bar).toHaveAttribute('aria-valuenow', '75');
|
|
1027
|
-
});
|
|
1028
|
-
|
|
1029
|
-
it('should render completion field value 50 as 50% with correct bar', () => {
|
|
1030
|
-
const { container } = render(
|
|
1031
|
-
<PercentCellRenderer
|
|
1032
|
-
value={50}
|
|
1033
|
-
field={{ name: 'completion', type: 'percent' } as any}
|
|
1034
|
-
/>
|
|
1035
|
-
);
|
|
1036
|
-
expect(screen.getByText('50%')).toBeInTheDocument();
|
|
1037
|
-
const bar = container.querySelector('[role="progressbar"]');
|
|
1038
|
-
expect(bar).toHaveAttribute('aria-valuenow', '50');
|
|
1039
|
-
});
|
|
1040
|
-
|
|
1041
|
-
it('should render probability field value 0.75 as 75%', () => {
|
|
1042
|
-
const { container } = render(
|
|
1043
|
-
<PercentCellRenderer
|
|
1044
|
-
value={0.75}
|
|
1045
|
-
field={{ name: 'probability', type: 'percent' } as any}
|
|
1046
|
-
/>
|
|
1047
|
-
);
|
|
1048
|
-
expect(screen.getByText('75%')).toBeInTheDocument();
|
|
1049
|
-
const bar = container.querySelector('[role="progressbar"]');
|
|
1050
|
-
expect(bar).toHaveAttribute('aria-valuenow', '75');
|
|
1051
|
-
});
|
|
1052
|
-
|
|
1053
|
-
it('should render rate field value 0.5 as 50%', () => {
|
|
1054
|
-
const { container } = render(
|
|
1055
|
-
<PercentCellRenderer
|
|
1056
|
-
value={0.5}
|
|
1057
|
-
field={{ name: 'rate', type: 'percent' } as any}
|
|
1058
|
-
/>
|
|
1059
|
-
);
|
|
1060
|
-
expect(screen.getByText('50%')).toBeInTheDocument();
|
|
1061
|
-
const bar = container.querySelector('[role="progressbar"]');
|
|
1062
|
-
expect(bar).toHaveAttribute('aria-valuenow', '50');
|
|
1063
|
-
});
|
|
1064
|
-
});
|
|
1065
|
-
|
|
1066
|
-
// =========================================================================
|
|
1067
|
-
// Object value safety (coerceToSafeValue) — prevents React error #310
|
|
1068
|
-
// =========================================================================
|
|
1069
|
-
import {
|
|
1070
|
-
NumberCellRenderer,
|
|
1071
|
-
CurrencyCellRenderer,
|
|
1072
|
-
FormulaCellRenderer,
|
|
1073
|
-
coerceToSafeValue,
|
|
1074
|
-
} from '../index';
|
|
1075
|
-
|
|
1076
|
-
describe('coerceToSafeValue', () => {
|
|
1077
|
-
it('should pass through primitives unchanged', () => {
|
|
1078
|
-
expect(coerceToSafeValue('hello')).toBe('hello');
|
|
1079
|
-
expect(coerceToSafeValue(42)).toBe(42);
|
|
1080
|
-
expect(coerceToSafeValue(true)).toBe(true);
|
|
1081
|
-
expect(coerceToSafeValue(null)).toBe(null);
|
|
1082
|
-
expect(coerceToSafeValue(undefined)).toBe(undefined);
|
|
1083
|
-
});
|
|
1084
|
-
|
|
1085
|
-
it('should extract number from MongoDB $numberDecimal', () => {
|
|
1086
|
-
expect(coerceToSafeValue({ $numberDecimal: '250000' })).toBe(250000);
|
|
1087
|
-
});
|
|
1088
|
-
|
|
1089
|
-
it('should extract string from MongoDB $oid', () => {
|
|
1090
|
-
expect(coerceToSafeValue({ $oid: 'abc123' })).toBe('abc123');
|
|
1091
|
-
});
|
|
1092
|
-
|
|
1093
|
-
it('should extract string from MongoDB $date', () => {
|
|
1094
|
-
expect(coerceToSafeValue({ $date: '2024-01-01T00:00:00Z' })).toBe('2024-01-01T00:00:00Z');
|
|
1095
|
-
});
|
|
1096
|
-
|
|
1097
|
-
it('should extract name from expanded reference object', () => {
|
|
1098
|
-
expect(coerceToSafeValue({ id: 'x', name: 'Acme Corp' })).toBe('Acme Corp');
|
|
1099
|
-
});
|
|
1100
|
-
|
|
1101
|
-
it('should extract label when name is not present', () => {
|
|
1102
|
-
expect(coerceToSafeValue({ id: 'x', label: 'Active' })).toBe('Active');
|
|
1103
|
-
});
|
|
1104
|
-
|
|
1105
|
-
it('should fall back to id when no name/label', () => {
|
|
1106
|
-
expect(coerceToSafeValue({ id: 'abc123' })).toBe('abc123');
|
|
1107
|
-
});
|
|
1108
|
-
|
|
1109
|
-
it('should handle arrays of primitives', () => {
|
|
1110
|
-
expect(coerceToSafeValue(['a', 'b', 'c'])).toBe('a, b, c');
|
|
1111
|
-
});
|
|
1112
|
-
|
|
1113
|
-
it('should handle arrays of objects', () => {
|
|
1114
|
-
expect(coerceToSafeValue([{ name: 'Alice' }, { name: 'Bob' }])).toBe('Alice, Bob');
|
|
1115
|
-
});
|
|
1116
|
-
|
|
1117
|
-
it('should convert Date to ISO string', () => {
|
|
1118
|
-
const d = new Date('2024-06-15T12:00:00Z');
|
|
1119
|
-
expect(coerceToSafeValue(d)).toBe('2024-06-15T12:00:00.000Z');
|
|
1120
|
-
});
|
|
1121
|
-
});
|
|
1122
|
-
|
|
1123
|
-
describe('NumberCellRenderer object safety', () => {
|
|
1124
|
-
it('should handle MongoDB $numberDecimal without crashing', () => {
|
|
1125
|
-
const { container } = render(
|
|
1126
|
-
<NumberCellRenderer
|
|
1127
|
-
value={{ $numberDecimal: '250000' }}
|
|
1128
|
-
field={{ name: 'amount', type: 'number' } as any}
|
|
1129
|
-
/>
|
|
1130
|
-
);
|
|
1131
|
-
expect(container.innerHTML).not.toBe('');
|
|
1132
|
-
expect(screen.getByText('250,000')).toBeInTheDocument();
|
|
1133
|
-
});
|
|
1134
|
-
|
|
1135
|
-
it('should handle expanded reference object without crashing', () => {
|
|
1136
|
-
const { container } = render(
|
|
1137
|
-
<NumberCellRenderer
|
|
1138
|
-
value={{ id: 'abc', name: 'Not a number' }}
|
|
1139
|
-
field={{ name: 'amount', type: 'number' } as any}
|
|
1140
|
-
/>
|
|
1141
|
-
);
|
|
1142
|
-
expect(container.innerHTML).not.toBe('');
|
|
1143
|
-
// Should render the extracted name string, not crash
|
|
1144
|
-
expect(screen.getByText('Not a number')).toBeInTheDocument();
|
|
1145
|
-
});
|
|
1146
|
-
});
|
|
1147
|
-
|
|
1148
|
-
describe('CurrencyCellRenderer object safety', () => {
|
|
1149
|
-
it('should handle MongoDB $numberDecimal', () => {
|
|
1150
|
-
const { container } = render(
|
|
1151
|
-
<CurrencyCellRenderer
|
|
1152
|
-
value={{ $numberDecimal: '5000' }}
|
|
1153
|
-
field={{ name: 'price', type: 'currency', currency: 'USD' } as any}
|
|
1154
|
-
/>
|
|
1155
|
-
);
|
|
1156
|
-
expect(container.innerHTML).not.toBe('');
|
|
1157
|
-
expect(screen.getByText(/5,000/)).toBeInTheDocument();
|
|
1158
|
-
});
|
|
1159
|
-
});
|
|
1160
|
-
|
|
1161
|
-
describe('TextCellRenderer object safety', () => {
|
|
1162
|
-
it('should extract name from object instead of [object Object]', () => {
|
|
1163
|
-
render(
|
|
1164
|
-
<TextCellRenderer
|
|
1165
|
-
value={{ id: 'abc', name: 'Acme Corp' }}
|
|
1166
|
-
field={{ name: 'company', type: 'text' } as any}
|
|
1167
|
-
/>
|
|
1168
|
-
);
|
|
1169
|
-
expect(screen.getByText('Acme Corp')).toBeInTheDocument();
|
|
1170
|
-
});
|
|
1171
|
-
|
|
1172
|
-
it('should handle arrays of objects', () => {
|
|
1173
|
-
render(
|
|
1174
|
-
<TextCellRenderer
|
|
1175
|
-
value={[{ name: 'Alice' }, { name: 'Bob' }]}
|
|
1176
|
-
field={{ name: 'contacts', type: 'text' } as any}
|
|
1177
|
-
/>
|
|
1178
|
-
);
|
|
1179
|
-
expect(screen.getByText('Alice, Bob')).toBeInTheDocument();
|
|
1180
|
-
});
|
|
1181
|
-
});
|
|
1182
|
-
|
|
1183
|
-
describe('FormulaCellRenderer object safety', () => {
|
|
1184
|
-
it('should extract value from MongoDB $numberDecimal', () => {
|
|
1185
|
-
render(
|
|
1186
|
-
<FormulaCellRenderer
|
|
1187
|
-
value={{ $numberDecimal: '42.5' }}
|
|
1188
|
-
field={{ name: 'calc', type: 'formula' } as any}
|
|
1189
|
-
/>
|
|
1190
|
-
);
|
|
1191
|
-
expect(screen.getByText('42.5')).toBeInTheDocument();
|
|
1192
|
-
});
|
|
1193
|
-
});
|