@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.
- package/package.json +21 -23
- package/src/__mocks__/next/link.js +11 -0
- package/src/components/account/__tests__/account.test.tsx +315 -0
- package/src/components/command-palette/CommandGroup.tsx +23 -0
- package/src/components/command-palette/CommandPalette.tsx +183 -200
- package/src/components/command-palette/CommandResultItem.tsx +59 -0
- package/src/components/command-palette/__tests__/CommandGroup.test.tsx +81 -0
- package/src/components/command-palette/__tests__/CommandResultItem.test.tsx +166 -0
- package/src/components/command-palette/__tests__/command-palette-context.test.tsx +166 -0
- package/src/components/command-palette/__tests__/useCommandPaletteSearch.test.ts +271 -0
- package/src/components/command-palette/index.ts +6 -0
- package/src/components/command-palette/useCommandPaletteSearch.ts +114 -0
- package/src/components/compose/__tests__/compose.test.tsx +656 -0
- package/src/components/dashboard/PipelineFunnel.tsx +126 -0
- package/src/components/dashboard/TopCampaigns.tsx +132 -0
- package/src/components/dashboard/__tests__/dashboard.test.tsx +785 -0
- package/src/components/dashboard/index.ts +6 -0
- package/src/components/dialog/ConfirmDialog.tsx +72 -0
- package/src/components/dialog/__tests__/ConfirmDialog.test.tsx +126 -0
- package/src/components/dialog/index.ts +3 -0
- package/src/components/email-dialogs/__tests__/email-dialogs.test.tsx +982 -0
- package/src/components/email-editor/BlockRenderer.tsx +120 -0
- package/src/components/email-editor/__tests__/BlockRenderer.test.tsx +332 -0
- package/src/components/email-editor/__tests__/block-renderers.test.ts +624 -0
- package/src/components/email-editor/__tests__/email-html-renderer.test.ts +376 -0
- package/src/components/email-editor/blocks/__tests__/blocks.test.tsx +818 -0
- package/src/components/email-editor/editor-sidebar.tsx +6 -731
- package/src/components/email-editor/email-editor.tsx +78 -467
- package/src/components/email-editor/hooks/__tests__/useDragDrop.test.ts +355 -0
- package/src/components/email-editor/hooks/__tests__/useEmailEditorState.test.ts +551 -0
- package/src/components/email-editor/hooks/useDragDrop.ts +181 -0
- package/src/components/email-editor/hooks/useEmailEditorState.ts +426 -0
- package/src/components/email-editor/index.ts +1 -0
- package/src/components/email-editor/panels/BlockPropertyPanel.tsx +637 -0
- package/src/components/email-editor/panels/GlobalStylesPanel.tsx +108 -0
- package/src/components/email-editor/panels/SectionSettingsPanel.tsx +80 -0
- package/src/components/email-editor/panels/__tests__/BlockPropertyPanel.test.tsx +707 -0
- package/src/components/email-editor/panels/__tests__/GlobalStylesPanel.test.tsx +226 -0
- package/src/components/email-editor/panels/index.ts +3 -0
- package/src/components/enrichment/__tests__/enrichment.test.tsx +184 -0
- package/src/components/gantt/GanttBoardView.tsx +71 -0
- package/src/components/gantt/GanttChart.tsx +134 -881
- package/src/components/gantt/GanttFilterBar.tsx +100 -0
- package/src/components/gantt/GanttListView.tsx +63 -0
- package/src/components/gantt/GanttTimelineView.tsx +215 -0
- package/src/components/gantt/__tests__/GanttBoardView.test.tsx +305 -0
- package/src/components/gantt/__tests__/GanttFilterBar.test.tsx +544 -0
- package/src/components/gantt/__tests__/GanttListView.test.tsx +337 -0
- package/src/components/gantt/__tests__/GanttTimelineView.test.tsx +375 -0
- package/src/components/gantt/__tests__/gantt-utils.test.ts +341 -0
- package/src/components/gantt/__tests__/useGanttState.test.ts +535 -0
- package/src/components/gantt/hooks/useGanttState.ts +644 -0
- package/src/components/gantt/index.ts +10 -0
- package/src/components/integrations/__tests__/integrations.test.tsx +191 -0
- package/src/components/kanban/__tests__/kanban.test.tsx +157 -0
- package/src/components/lists/__tests__/lists.test.tsx +263 -0
- package/src/components/loading/__tests__/loading.test.tsx +114 -0
- package/src/components/navigation/__tests__/navigation.test.tsx +194 -0
- package/src/components/pipeline/__tests__/pipeline.test.tsx +169 -0
- package/src/components/safe-html.tsx +9 -8
- package/src/components/settings/__tests__/settings.test.tsx +181 -0
- 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
|
+
})
|