@startsimpli/ui 0.4.7 → 0.4.9

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 (62) hide show
  1. package/package.json +21 -23
  2. package/src/__mocks__/next/link.js +11 -0
  3. package/src/components/account/__tests__/account.test.tsx +315 -0
  4. package/src/components/command-palette/CommandGroup.tsx +23 -0
  5. package/src/components/command-palette/CommandPalette.tsx +183 -200
  6. package/src/components/command-palette/CommandResultItem.tsx +59 -0
  7. package/src/components/command-palette/__tests__/CommandGroup.test.tsx +81 -0
  8. package/src/components/command-palette/__tests__/CommandResultItem.test.tsx +166 -0
  9. package/src/components/command-palette/__tests__/command-palette-context.test.tsx +166 -0
  10. package/src/components/command-palette/__tests__/useCommandPaletteSearch.test.ts +271 -0
  11. package/src/components/command-palette/index.ts +6 -0
  12. package/src/components/command-palette/useCommandPaletteSearch.ts +114 -0
  13. package/src/components/compose/__tests__/compose.test.tsx +656 -0
  14. package/src/components/dashboard/PipelineFunnel.tsx +126 -0
  15. package/src/components/dashboard/TopCampaigns.tsx +132 -0
  16. package/src/components/dashboard/__tests__/dashboard.test.tsx +785 -0
  17. package/src/components/dashboard/index.ts +6 -0
  18. package/src/components/dialog/ConfirmDialog.tsx +72 -0
  19. package/src/components/dialog/__tests__/ConfirmDialog.test.tsx +126 -0
  20. package/src/components/dialog/index.ts +3 -0
  21. package/src/components/email-dialogs/__tests__/email-dialogs.test.tsx +982 -0
  22. package/src/components/email-editor/BlockRenderer.tsx +120 -0
  23. package/src/components/email-editor/__tests__/BlockRenderer.test.tsx +332 -0
  24. package/src/components/email-editor/__tests__/block-renderers.test.ts +624 -0
  25. package/src/components/email-editor/__tests__/email-html-renderer.test.ts +376 -0
  26. package/src/components/email-editor/blocks/__tests__/blocks.test.tsx +818 -0
  27. package/src/components/email-editor/editor-sidebar.tsx +6 -731
  28. package/src/components/email-editor/email-editor.tsx +78 -467
  29. package/src/components/email-editor/hooks/__tests__/useDragDrop.test.ts +355 -0
  30. package/src/components/email-editor/hooks/__tests__/useEmailEditorState.test.ts +551 -0
  31. package/src/components/email-editor/hooks/useDragDrop.ts +181 -0
  32. package/src/components/email-editor/hooks/useEmailEditorState.ts +426 -0
  33. package/src/components/email-editor/index.ts +1 -0
  34. package/src/components/email-editor/panels/BlockPropertyPanel.tsx +637 -0
  35. package/src/components/email-editor/panels/GlobalStylesPanel.tsx +108 -0
  36. package/src/components/email-editor/panels/SectionSettingsPanel.tsx +80 -0
  37. package/src/components/email-editor/panels/__tests__/BlockPropertyPanel.test.tsx +707 -0
  38. package/src/components/email-editor/panels/__tests__/GlobalStylesPanel.test.tsx +226 -0
  39. package/src/components/email-editor/panels/index.ts +3 -0
  40. package/src/components/enrichment/__tests__/enrichment.test.tsx +184 -0
  41. package/src/components/gantt/GanttBoardView.tsx +71 -0
  42. package/src/components/gantt/GanttChart.tsx +134 -881
  43. package/src/components/gantt/GanttFilterBar.tsx +100 -0
  44. package/src/components/gantt/GanttListView.tsx +63 -0
  45. package/src/components/gantt/GanttTimelineView.tsx +215 -0
  46. package/src/components/gantt/__tests__/GanttBoardView.test.tsx +305 -0
  47. package/src/components/gantt/__tests__/GanttFilterBar.test.tsx +544 -0
  48. package/src/components/gantt/__tests__/GanttListView.test.tsx +337 -0
  49. package/src/components/gantt/__tests__/GanttTimelineView.test.tsx +375 -0
  50. package/src/components/gantt/__tests__/gantt-utils.test.ts +341 -0
  51. package/src/components/gantt/__tests__/useGanttState.test.ts +535 -0
  52. package/src/components/gantt/hooks/useGanttState.ts +644 -0
  53. package/src/components/gantt/index.ts +10 -0
  54. package/src/components/integrations/__tests__/integrations.test.tsx +191 -0
  55. package/src/components/kanban/__tests__/kanban.test.tsx +157 -0
  56. package/src/components/lists/__tests__/lists.test.tsx +263 -0
  57. package/src/components/loading/__tests__/loading.test.tsx +114 -0
  58. package/src/components/navigation/__tests__/navigation.test.tsx +194 -0
  59. package/src/components/pipeline/__tests__/pipeline.test.tsx +169 -0
  60. package/src/components/safe-html.tsx +9 -8
  61. package/src/components/settings/__tests__/settings.test.tsx +181 -0
  62. package/src/components/wizard/__tests__/wizard.test.tsx +97 -0
@@ -0,0 +1,535 @@
1
+ import { renderHook, act } from '@testing-library/react'
2
+ import { useGanttState, ZOOM_LEVELS } from '../hooks/useGanttState'
3
+ import type { GanttChartProps, TimelineItem } from '../types'
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Fixtures
7
+ // ---------------------------------------------------------------------------
8
+
9
+ const makeItem = (overrides: Partial<TimelineItem> & { id: string; title: string; status: string }): TimelineItem => ({
10
+ startDate: '2025-01-01',
11
+ endDate: '2025-03-31',
12
+ ...overrides,
13
+ })
14
+
15
+ const ITEMS: TimelineItem[] = [
16
+ makeItem({ id: 'a', title: 'Alpha', status: 'not_started', category: 'product' }),
17
+ makeItem({ id: 'b', title: 'Beta', status: 'in_progress', category: 'team', startDate: '2025-04-01', endDate: '2025-06-30' }),
18
+ makeItem({ id: 'c', title: 'Gamma', status: 'completed', category: 'product', startDate: '2025-07-01', endDate: '2025-09-30' }),
19
+ ]
20
+
21
+ // The hook builds its itemMap from the flat top-level items array.
22
+ // Children must appear both nested inside parent.children (for the relationship)
23
+ // AND as standalone entries in the flat array (so itemMap.has(child.id) succeeds).
24
+ const CHILD1 = makeItem({ id: 'child1', title: 'Child One', status: 'not_started' })
25
+ const CHILD2 = makeItem({ id: 'child2', title: 'Child Two', status: 'in_progress' })
26
+ const PARENT_ITEM = makeItem({
27
+ id: 'parent',
28
+ title: 'Parent',
29
+ status: 'in_progress',
30
+ children: [CHILD1, CHILD2],
31
+ })
32
+ const ITEMS_WITH_CHILDREN: TimelineItem[] = [PARENT_ITEM, CHILD1, CHILD2]
33
+
34
+ function defaultProps(overrides: Partial<GanttChartProps> = {}): GanttChartProps {
35
+ return { items: ITEMS, showFilterBar: false, ...overrides }
36
+ }
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Zoom
40
+ // ---------------------------------------------------------------------------
41
+
42
+ describe('useGanttState — zoom', () => {
43
+ it('starts at the default zoom index (3)', () => {
44
+ const { result } = renderHook(() => useGanttState(defaultProps()))
45
+ expect(result.current.zoomIndex).toBe(3)
46
+ expect(result.current.dayWidth).toBe(ZOOM_LEVELS[3])
47
+ })
48
+
49
+ it('respects initialZoom prop', () => {
50
+ const { result } = renderHook(() => useGanttState(defaultProps({ initialZoom: 1 })))
51
+ expect(result.current.zoomIndex).toBe(1)
52
+ expect(result.current.dayWidth).toBe(ZOOM_LEVELS[1])
53
+ })
54
+
55
+ it('zooms in by incrementing zoomIndex', () => {
56
+ const { result } = renderHook(() => useGanttState(defaultProps()))
57
+ act(() => { result.current.setZoomIndex(result.current.zoomIndex + 1) })
58
+ expect(result.current.zoomIndex).toBe(4)
59
+ expect(result.current.dayWidth).toBe(ZOOM_LEVELS[4])
60
+ })
61
+
62
+ it('zooms out by decrementing zoomIndex', () => {
63
+ const { result } = renderHook(() => useGanttState(defaultProps()))
64
+ act(() => { result.current.setZoomIndex(result.current.zoomIndex - 1) })
65
+ expect(result.current.zoomIndex).toBe(2)
66
+ expect(result.current.dayWidth).toBe(ZOOM_LEVELS[2])
67
+ })
68
+ })
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // View mode
72
+ // ---------------------------------------------------------------------------
73
+
74
+ describe('useGanttState — view mode', () => {
75
+ it('defaults to "timeline"', () => {
76
+ const { result } = renderHook(() => useGanttState(defaultProps()))
77
+ expect(result.current.viewMode).toBe('timeline')
78
+ })
79
+
80
+ it('respects initialViewMode prop', () => {
81
+ const { result } = renderHook(() => useGanttState(defaultProps({ initialViewMode: 'board' })))
82
+ expect(result.current.viewMode).toBe('board')
83
+ })
84
+
85
+ it('switches to board mode', () => {
86
+ const { result } = renderHook(() => useGanttState(defaultProps()))
87
+ act(() => { result.current.setViewMode('board') })
88
+ expect(result.current.viewMode).toBe('board')
89
+ })
90
+
91
+ it('switches to list mode', () => {
92
+ const { result } = renderHook(() => useGanttState(defaultProps()))
93
+ act(() => { result.current.setViewMode('list') })
94
+ expect(result.current.viewMode).toBe('list')
95
+ })
96
+
97
+ it('switches back to timeline mode', () => {
98
+ const { result } = renderHook(() => useGanttState(defaultProps()))
99
+ act(() => { result.current.setViewMode('board') })
100
+ act(() => { result.current.setViewMode('timeline') })
101
+ expect(result.current.viewMode).toBe('timeline')
102
+ })
103
+ })
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // Collapse / expand
107
+ // ---------------------------------------------------------------------------
108
+
109
+ describe('useGanttState — collapse/expand', () => {
110
+ it('starts with no collapsed items', () => {
111
+ const { result } = renderHook(() => useGanttState(defaultProps({ items: ITEMS_WITH_CHILDREN })))
112
+ expect(result.current.collapsed.size).toBe(0)
113
+ })
114
+
115
+ it('collapses an item', () => {
116
+ const { result } = renderHook(() => useGanttState(defaultProps({ items: ITEMS_WITH_CHILDREN })))
117
+ act(() => { result.current.toggleCollapse('parent') })
118
+ expect(result.current.collapsed.has('parent')).toBe(true)
119
+ })
120
+
121
+ it('expands a previously collapsed item', () => {
122
+ const { result } = renderHook(() => useGanttState(defaultProps({ items: ITEMS_WITH_CHILDREN })))
123
+ act(() => { result.current.toggleCollapse('parent') })
124
+ act(() => { result.current.toggleCollapse('parent') })
125
+ expect(result.current.collapsed.has('parent')).toBe(false)
126
+ })
127
+
128
+ it('collapsing a parent hides its children from the task list', () => {
129
+ const { result } = renderHook(() =>
130
+ useGanttState(defaultProps({ items: ITEMS_WITH_CHILDREN, hierarchical: true }))
131
+ )
132
+ const beforeCollapse = result.current.tasks.length
133
+
134
+ act(() => { result.current.toggleCollapse('parent') })
135
+
136
+ expect(result.current.tasks.length).toBeLessThan(beforeCollapse)
137
+ })
138
+ })
139
+
140
+ // ---------------------------------------------------------------------------
141
+ // Filter — search
142
+ // ---------------------------------------------------------------------------
143
+
144
+ describe('useGanttState — filter: search', () => {
145
+ it('returns all items when showFilterBar is false regardless of search', () => {
146
+ const { result } = renderHook(() => useGanttState(defaultProps({ showFilterBar: false })))
147
+ act(() => {
148
+ result.current.setFilters((prev) => ({ ...prev, search: 'Alpha' }))
149
+ })
150
+ expect(result.current.filteredItems).toHaveLength(ITEMS.length)
151
+ })
152
+
153
+ it('filters by title search when showFilterBar is true', () => {
154
+ const { result } = renderHook(() => useGanttState(defaultProps({ showFilterBar: true })))
155
+ act(() => {
156
+ result.current.setFilters((prev) => ({ ...prev, search: 'Alpha' }))
157
+ })
158
+ expect(result.current.filteredItems).toHaveLength(1)
159
+ expect(result.current.filteredItems[0].id).toBe('a')
160
+ })
161
+
162
+ it('search is case-insensitive', () => {
163
+ const { result } = renderHook(() => useGanttState(defaultProps({ showFilterBar: true })))
164
+ act(() => {
165
+ result.current.setFilters((prev) => ({ ...prev, search: 'alpha' }))
166
+ })
167
+ expect(result.current.filteredItems).toHaveLength(1)
168
+ })
169
+
170
+ it('returns empty array when no items match the search', () => {
171
+ const { result } = renderHook(() => useGanttState(defaultProps({ showFilterBar: true })))
172
+ act(() => {
173
+ result.current.setFilters((prev) => ({ ...prev, search: 'zzz-no-match' }))
174
+ })
175
+ expect(result.current.filteredItems).toHaveLength(0)
176
+ })
177
+
178
+ it('matches against description as well as title', () => {
179
+ const itemsWithDesc: TimelineItem[] = [
180
+ makeItem({ id: 'x', title: 'Task X', status: 'not_started', description: 'special-keyword' }),
181
+ ]
182
+ const { result } = renderHook(() =>
183
+ useGanttState(defaultProps({ items: itemsWithDesc, showFilterBar: true }))
184
+ )
185
+ act(() => {
186
+ result.current.setFilters((prev) => ({ ...prev, search: 'special-keyword' }))
187
+ })
188
+ expect(result.current.filteredItems).toHaveLength(1)
189
+ })
190
+ })
191
+
192
+ // ---------------------------------------------------------------------------
193
+ // Filter — status
194
+ // ---------------------------------------------------------------------------
195
+
196
+ describe('useGanttState — filter: status', () => {
197
+ it('filters items by a single status', () => {
198
+ const { result } = renderHook(() => useGanttState(defaultProps({ showFilterBar: true })))
199
+ act(() => {
200
+ result.current.setFilters((prev) => ({ ...prev, statuses: ['completed'] }))
201
+ })
202
+ expect(result.current.filteredItems.every((i) => i.status === 'completed')).toBe(true)
203
+ })
204
+
205
+ it('filters items by multiple statuses', () => {
206
+ const { result } = renderHook(() => useGanttState(defaultProps({ showFilterBar: true })))
207
+ act(() => {
208
+ result.current.setFilters((prev) => ({ ...prev, statuses: ['not_started', 'completed'] }))
209
+ })
210
+ const ids = result.current.filteredItems.map((i) => i.id)
211
+ expect(ids).toContain('a')
212
+ expect(ids).toContain('c')
213
+ expect(ids).not.toContain('b')
214
+ })
215
+ })
216
+
217
+ // ---------------------------------------------------------------------------
218
+ // Filter — category
219
+ // ---------------------------------------------------------------------------
220
+
221
+ describe('useGanttState — filter: category', () => {
222
+ it('filters items by category', () => {
223
+ const { result } = renderHook(() => useGanttState(defaultProps({ showFilterBar: true })))
224
+ act(() => {
225
+ result.current.setFilters((prev) => ({ ...prev, categories: ['team'] }))
226
+ })
227
+ expect(result.current.filteredItems).toHaveLength(1)
228
+ expect(result.current.filteredItems[0].id).toBe('b')
229
+ })
230
+
231
+ it('shows items from multiple categories', () => {
232
+ const { result } = renderHook(() => useGanttState(defaultProps({ showFilterBar: true })))
233
+ act(() => {
234
+ result.current.setFilters((prev) => ({ ...prev, categories: ['product', 'team'] }))
235
+ })
236
+ expect(result.current.filteredItems).toHaveLength(3)
237
+ })
238
+ })
239
+
240
+ // ---------------------------------------------------------------------------
241
+ // Filter — date range
242
+ // ---------------------------------------------------------------------------
243
+
244
+ describe('useGanttState — filter: date range', () => {
245
+ it('excludes items that end before the filter start date', () => {
246
+ const { result } = renderHook(() => useGanttState(defaultProps({ showFilterBar: true })))
247
+ // Items A ends 2025-03-31; B and C start after April 1
248
+ act(() => {
249
+ result.current.setFilters((prev) => ({
250
+ ...prev,
251
+ dateRange: { start: new Date('2025-04-01'), end: null },
252
+ }))
253
+ })
254
+ const ids = result.current.filteredItems.map((i) => i.id)
255
+ expect(ids).not.toContain('a')
256
+ expect(ids).toContain('b')
257
+ expect(ids).toContain('c')
258
+ })
259
+
260
+ it('excludes items that start after the filter end date', () => {
261
+ const { result } = renderHook(() => useGanttState(defaultProps({ showFilterBar: true })))
262
+ // Items C starts 2025-07-01; only A and B start before July 1
263
+ act(() => {
264
+ result.current.setFilters((prev) => ({
265
+ ...prev,
266
+ dateRange: { start: null, end: new Date('2025-06-30') },
267
+ }))
268
+ })
269
+ const ids = result.current.filteredItems.map((i) => i.id)
270
+ expect(ids).toContain('a')
271
+ expect(ids).toContain('b')
272
+ expect(ids).not.toContain('c')
273
+ })
274
+ })
275
+
276
+ // ---------------------------------------------------------------------------
277
+ // hasActiveFilters
278
+ // ---------------------------------------------------------------------------
279
+
280
+ describe('useGanttState — hasActiveFilters', () => {
281
+ it('is falsy when all filters are at their defaults', () => {
282
+ const { result } = renderHook(() => useGanttState(defaultProps({ showFilterBar: true })))
283
+ expect(result.current.hasActiveFilters).toBeFalsy()
284
+ })
285
+
286
+ it('is truthy when a search term is set', () => {
287
+ const { result } = renderHook(() => useGanttState(defaultProps({ showFilterBar: true })))
288
+ act(() => {
289
+ result.current.setFilters((prev) => ({ ...prev, search: 'foo' }))
290
+ })
291
+ expect(result.current.hasActiveFilters).toBeTruthy()
292
+ })
293
+
294
+ it('is truthy when status filter is active', () => {
295
+ const { result } = renderHook(() => useGanttState(defaultProps({ showFilterBar: true })))
296
+ act(() => {
297
+ result.current.setFilters((prev) => ({ ...prev, statuses: ['completed'] }))
298
+ })
299
+ expect(result.current.hasActiveFilters).toBeTruthy()
300
+ })
301
+ })
302
+
303
+ // ---------------------------------------------------------------------------
304
+ // uniqueStatuses / uniqueCategories
305
+ // ---------------------------------------------------------------------------
306
+
307
+ describe('useGanttState — uniqueStatuses and uniqueCategories', () => {
308
+ it('extracts unique statuses from items', () => {
309
+ const { result } = renderHook(() => useGanttState(defaultProps()))
310
+ expect(result.current.uniqueStatuses).toContain('not_started')
311
+ expect(result.current.uniqueStatuses).toContain('in_progress')
312
+ expect(result.current.uniqueStatuses).toContain('completed')
313
+ })
314
+
315
+ it('returns statuses sorted alphabetically', () => {
316
+ const { result } = renderHook(() => useGanttState(defaultProps()))
317
+ const sorted = [...result.current.uniqueStatuses].sort()
318
+ expect(result.current.uniqueStatuses).toEqual(sorted)
319
+ })
320
+
321
+ it('extracts unique categories from items', () => {
322
+ const { result } = renderHook(() => useGanttState(defaultProps()))
323
+ expect(result.current.uniqueCategories).toContain('product')
324
+ expect(result.current.uniqueCategories).toContain('team')
325
+ })
326
+
327
+ it('collects categories from nested children', () => {
328
+ const nested: TimelineItem[] = [
329
+ makeItem({
330
+ id: 'parent',
331
+ title: 'Parent',
332
+ status: 'in_progress',
333
+ category: 'financial',
334
+ children: [
335
+ makeItem({ id: 'child', title: 'Child', status: 'not_started', category: 'market' }),
336
+ ],
337
+ }),
338
+ ]
339
+ const { result } = renderHook(() => useGanttState(defaultProps({ items: nested })))
340
+ expect(result.current.uniqueCategories).toContain('financial')
341
+ expect(result.current.uniqueCategories).toContain('market')
342
+ })
343
+ })
344
+
345
+ // ---------------------------------------------------------------------------
346
+ // handleItemClick / detailItem
347
+ // ---------------------------------------------------------------------------
348
+
349
+ describe('useGanttState — handleItemClick', () => {
350
+ it('calls onItemClick when an item is clicked', () => {
351
+ const onItemClick = jest.fn()
352
+ const { result } = renderHook(() => useGanttState(defaultProps({ onItemClick })))
353
+ act(() => { result.current.handleItemClick(ITEMS[0]) })
354
+ expect(onItemClick).toHaveBeenCalledWith(ITEMS[0])
355
+ })
356
+
357
+ it('does not set detailItem when onItemEdit is not provided', () => {
358
+ const { result } = renderHook(() => useGanttState(defaultProps()))
359
+ act(() => { result.current.handleItemClick(ITEMS[0]) })
360
+ expect(result.current.detailItem).toBeNull()
361
+ })
362
+
363
+ it('sets detailItem when onItemEdit is provided', () => {
364
+ const onItemEdit = jest.fn()
365
+ const { result } = renderHook(() => useGanttState(defaultProps({ onItemEdit })))
366
+ act(() => { result.current.handleItemClick(ITEMS[1]) })
367
+ expect(result.current.detailItem).toBe(ITEMS[1])
368
+ })
369
+
370
+ it('calls onItemClick in addition to setting detailItem', () => {
371
+ const onItemClick = jest.fn()
372
+ const onItemEdit = jest.fn()
373
+ const { result } = renderHook(() => useGanttState(defaultProps({ onItemClick, onItemEdit })))
374
+ act(() => { result.current.handleItemClick(ITEMS[0]) })
375
+ expect(onItemClick).toHaveBeenCalledWith(ITEMS[0])
376
+ expect(result.current.detailItem).toBe(ITEMS[0])
377
+ })
378
+
379
+ it('allows manually clearing detailItem', () => {
380
+ const onItemEdit = jest.fn()
381
+ const { result } = renderHook(() => useGanttState(defaultProps({ onItemEdit })))
382
+ act(() => { result.current.handleItemClick(ITEMS[0]) })
383
+ expect(result.current.detailItem).not.toBeNull()
384
+ act(() => { result.current.setDetailItem(null) })
385
+ expect(result.current.detailItem).toBeNull()
386
+ })
387
+ })
388
+
389
+ // ---------------------------------------------------------------------------
390
+ // Board columns
391
+ // ---------------------------------------------------------------------------
392
+
393
+ describe('useGanttState — board columns', () => {
394
+ it('boardColumns is empty in timeline mode', () => {
395
+ const { result } = renderHook(() => useGanttState(defaultProps({ initialViewMode: 'timeline' })))
396
+ expect(result.current.boardColumns).toHaveLength(0)
397
+ })
398
+
399
+ it('boardColumns is populated in board mode', () => {
400
+ const { result } = renderHook(() => useGanttState(defaultProps({ initialViewMode: 'board' })))
401
+ expect(result.current.boardColumns.length).toBeGreaterThan(0)
402
+ })
403
+
404
+ it('each board column has a status and items array', () => {
405
+ const { result } = renderHook(() => useGanttState(defaultProps({ initialViewMode: 'board' })))
406
+ for (const col of result.current.boardColumns) {
407
+ expect(col).toHaveProperty('status')
408
+ expect(Array.isArray(col.items)).toBe(true)
409
+ }
410
+ })
411
+
412
+ it('uses boardStatuses prop when provided', () => {
413
+ const boardStatuses = ['not_started', 'completed']
414
+ const { result } = renderHook(() =>
415
+ useGanttState(defaultProps({ initialViewMode: 'board', boardStatuses }))
416
+ )
417
+ const statuses = result.current.boardColumns.map((c) => c.status)
418
+ expect(statuses).toEqual(boardStatuses)
419
+ })
420
+ })
421
+
422
+ // ---------------------------------------------------------------------------
423
+ // List items
424
+ // ---------------------------------------------------------------------------
425
+
426
+ describe('useGanttState — list items', () => {
427
+ it('listItems is empty in timeline mode', () => {
428
+ const { result } = renderHook(() => useGanttState(defaultProps({ initialViewMode: 'timeline' })))
429
+ expect(result.current.listItems).toHaveLength(0)
430
+ })
431
+
432
+ it('listItems is populated in list mode', () => {
433
+ const { result } = renderHook(() => useGanttState(defaultProps({ initialViewMode: 'list' })))
434
+ expect(result.current.listItems.length).toBe(result.current.tasks.length)
435
+ })
436
+ })
437
+
438
+ // ---------------------------------------------------------------------------
439
+ // Tasks — derived from items
440
+ // ---------------------------------------------------------------------------
441
+
442
+ describe('useGanttState — tasks', () => {
443
+ it('produces a task for every top-level item', () => {
444
+ const { result } = renderHook(() => useGanttState(defaultProps()))
445
+ expect(result.current.tasks).toHaveLength(ITEMS.length)
446
+ })
447
+
448
+ it('each task carries the original item reference', () => {
449
+ const { result } = renderHook(() => useGanttState(defaultProps()))
450
+ const taskIds = result.current.tasks.map((t) => t.item.id)
451
+ expect(taskIds).toEqual(expect.arrayContaining(ITEMS.map((i) => i.id)))
452
+ })
453
+
454
+ it('task depth is 0 for root items', () => {
455
+ const { result } = renderHook(() => useGanttState(defaultProps()))
456
+ expect(result.current.tasks.every((t) => t.depth === 0)).toBe(true)
457
+ })
458
+
459
+ it('child tasks have depth 1', () => {
460
+ const { result } = renderHook(() =>
461
+ useGanttState(defaultProps({ items: ITEMS_WITH_CHILDREN, hierarchical: true }))
462
+ )
463
+ const childTasks = result.current.tasks.filter((t) => t.depth === 1)
464
+ expect(childTasks.length).toBeGreaterThan(0)
465
+ })
466
+
467
+ it('hasChildren is true for the parent item', () => {
468
+ const { result } = renderHook(() =>
469
+ useGanttState(defaultProps({ items: ITEMS_WITH_CHILDREN, hierarchical: true }))
470
+ )
471
+ const parentTask = result.current.tasks.find((t) => t.item.id === 'parent')
472
+ expect(parentTask?.hasChildren).toBe(true)
473
+ })
474
+
475
+ it('uses statusToProgress when item has no explicit progress', () => {
476
+ const statusToProgress = jest.fn().mockReturnValue(42)
477
+ const { result } = renderHook(() =>
478
+ useGanttState(defaultProps({ statusToProgress }))
479
+ )
480
+ expect(result.current.tasks[0].progress).toBe(42)
481
+ })
482
+
483
+ it('prefers item.progress over statusToProgress', () => {
484
+ const itemsWithProgress: TimelineItem[] = [
485
+ makeItem({ id: 'p', title: 'With progress', status: 'in_progress', progress: 75 }),
486
+ ]
487
+ const statusToProgress = jest.fn().mockReturnValue(0)
488
+ const { result } = renderHook(() =>
489
+ useGanttState(defaultProps({ items: itemsWithProgress, statusToProgress }))
490
+ )
491
+ expect(result.current.tasks[0].progress).toBe(75)
492
+ expect(statusToProgress).not.toHaveBeenCalled()
493
+ })
494
+ })
495
+
496
+ // ---------------------------------------------------------------------------
497
+ // filteredItems computation
498
+ // ---------------------------------------------------------------------------
499
+
500
+ describe('useGanttState — filteredItems computation', () => {
501
+ it('keeps parent when child matches a search filter', () => {
502
+ const items: TimelineItem[] = [
503
+ makeItem({
504
+ id: 'parent',
505
+ title: 'Parent Item',
506
+ status: 'in_progress',
507
+ children: [
508
+ makeItem({ id: 'child', title: 'Matching Child', status: 'not_started' }),
509
+ ],
510
+ }),
511
+ ]
512
+ const { result } = renderHook(() =>
513
+ useGanttState(defaultProps({ items, showFilterBar: true }))
514
+ )
515
+ act(() => {
516
+ result.current.setFilters((prev) => ({ ...prev, search: 'Matching Child' }))
517
+ })
518
+ // Parent should be retained because child matched
519
+ const parentInFiltered = result.current.filteredItems.find((i) => i.id === 'parent')
520
+ expect(parentInFiltered).toBeDefined()
521
+ })
522
+
523
+ it('clears filteredItems to all items when filters are reset', () => {
524
+ const { result } = renderHook(() => useGanttState(defaultProps({ showFilterBar: true })))
525
+ act(() => {
526
+ result.current.setFilters((prev) => ({ ...prev, search: 'Alpha' }))
527
+ })
528
+ expect(result.current.filteredItems).toHaveLength(1)
529
+
530
+ act(() => {
531
+ result.current.setFilters((prev) => ({ ...prev, search: '' }))
532
+ })
533
+ expect(result.current.filteredItems).toHaveLength(ITEMS.length)
534
+ })
535
+ })