@discourser/design-system 0.15.0 → 0.15.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-QC44JPCA.cjs → chunk-ABC7N32K.cjs} +316 -10
- package/dist/chunk-ABC7N32K.cjs.map +1 -0
- package/dist/{chunk-F7LHARS4.js → chunk-GD6Q2FUE.js} +446 -6
- package/dist/chunk-GD6Q2FUE.js.map +1 -0
- package/dist/{chunk-M7J7WKJY.js → chunk-SBKRSXSZ.js} +317 -11
- package/dist/chunk-SBKRSXSZ.js.map +1 -0
- package/dist/{chunk-QP4EJI3G.cjs → chunk-UNWXE6UB.cjs} +450 -2
- package/dist/chunk-UNWXE6UB.cjs.map +1 -0
- package/dist/components/Breadcrumb.d.ts +9 -0
- package/dist/components/Breadcrumb.d.ts.map +1 -0
- package/dist/components/Checkbox.d.ts +6 -6
- package/dist/components/Icons/ClockIcon.d.ts +6 -0
- package/dist/components/Icons/ClockIcon.d.ts.map +1 -0
- package/dist/components/Icons/GripDotsVerticalIcon.d.ts +6 -0
- package/dist/components/Icons/GripDotsVerticalIcon.d.ts.map +1 -0
- package/dist/components/Icons/index.d.ts +3 -0
- package/dist/components/Icons/index.d.ts.map +1 -0
- package/dist/components/ScenarioQueue/AddScenarioDialog.d.ts +16 -0
- package/dist/components/ScenarioQueue/AddScenarioDialog.d.ts.map +1 -0
- package/dist/components/ScenarioQueue/ScenarioCard.d.ts +10 -0
- package/dist/components/ScenarioQueue/ScenarioCard.d.ts.map +1 -0
- package/dist/components/ScenarioQueue/ScenarioCardDraggable.d.ts +15 -0
- package/dist/components/ScenarioQueue/ScenarioCardDraggable.d.ts.map +1 -0
- package/dist/components/ScenarioQueue/ScenarioQueue.d.ts +3 -0
- package/dist/components/ScenarioQueue/ScenarioQueue.d.ts.map +1 -0
- package/dist/components/ScenarioQueue/index.d.ts +6 -0
- package/dist/components/ScenarioQueue/index.d.ts.map +1 -0
- package/dist/components/ScenarioQueue/types.d.ts +56 -0
- package/dist/components/ScenarioQueue/types.d.ts.map +1 -0
- package/dist/components/index.cjs +65 -33
- package/dist/components/index.d.ts +4 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +1 -1
- package/dist/index.cjs +69 -37
- package/dist/index.js +2 -2
- package/dist/preset/index.cjs +2 -2
- package/dist/preset/index.d.ts.map +1 -1
- package/dist/preset/index.js +1 -1
- package/dist/preset/recipes/avatar.d.ts.map +1 -1
- package/dist/preset/recipes/breadcrumb.d.ts +2 -0
- package/dist/preset/recipes/breadcrumb.d.ts.map +1 -0
- package/dist/preset/recipes/checkbox.d.ts.map +1 -1
- package/dist/preset/recipes/field.d.ts.map +1 -1
- package/dist/preset/recipes/index.d.ts +3 -0
- package/dist/preset/recipes/index.d.ts.map +1 -1
- package/dist/preset/recipes/progress.d.ts.map +1 -1
- package/dist/preset/recipes/radio-group.d.ts.map +1 -1
- package/dist/preset/recipes/scenario-card.d.ts +2 -0
- package/dist/preset/recipes/scenario-card.d.ts.map +1 -0
- package/dist/preset/recipes/scenario-queue.d.ts +2 -0
- package/dist/preset/recipes/scenario-queue.d.ts.map +1 -0
- package/dist/preset/recipes/steps.d.ts.map +1 -1
- package/dist/preset/recipes/toast.d.ts.map +1 -1
- package/dist/preset/recipes/tooltip.d.ts.map +1 -1
- package/dist/preset/semantic-tokens.d.ts +12 -0
- package/dist/preset/semantic-tokens.d.ts.map +1 -1
- package/package.json +10 -1
- package/src/components/Breadcrumb.tsx +34 -0
- package/src/components/Icons/ClockIcon.tsx +40 -0
- package/src/components/Icons/GripDotsVerticalIcon.tsx +26 -0
- package/src/components/Icons/index.ts +2 -0
- package/src/components/ScenarioQueue/AddScenarioDialog.tsx +137 -0
- package/src/components/ScenarioQueue/ScenarioCard.tsx +120 -0
- package/src/components/ScenarioQueue/ScenarioCardDraggable.tsx +41 -0
- package/src/components/ScenarioQueue/ScenarioQueue.test.tsx +398 -0
- package/src/components/ScenarioQueue/ScenarioQueue.tsx +162 -0
- package/src/components/ScenarioQueue/index.ts +11 -0
- package/src/components/ScenarioQueue/types.ts +86 -0
- package/src/components/index.ts +19 -0
- package/src/preset/index.ts +9 -0
- package/src/preset/recipes/avatar.ts +1 -2
- package/src/preset/recipes/breadcrumb.ts +77 -0
- package/src/preset/recipes/checkbox.ts +1 -2
- package/src/preset/recipes/field.ts +1 -2
- package/src/preset/recipes/index.ts +7 -0
- package/src/preset/recipes/progress.ts +1 -2
- package/src/preset/recipes/radio-group.ts +1 -2
- package/src/preset/recipes/scenario-card.ts +151 -0
- package/src/preset/recipes/scenario-queue.ts +99 -0
- package/src/preset/recipes/steps.ts +1 -2
- package/src/preset/recipes/toast.ts +1 -2
- package/src/preset/recipes/tooltip.ts +1 -2
- package/src/preset/semantic-tokens.ts +4 -0
- package/src/test/setup.ts +12 -0
- package/dist/chunk-F7LHARS4.js.map +0 -1
- package/dist/chunk-M7J7WKJY.js.map +0 -1
- package/dist/chunk-QC44JPCA.cjs.map +0 -1
- package/dist/chunk-QP4EJI3G.cjs.map +0 -1
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
/* global describe, it, expect, vi, beforeEach */
|
|
2
|
+
import React from 'react'
|
|
3
|
+
import { render, screen, act } from '@testing-library/react'
|
|
4
|
+
import userEvent from '@testing-library/user-event'
|
|
5
|
+
import { ScenarioQueue } from './ScenarioQueue'
|
|
6
|
+
import type { Scenario } from './types'
|
|
7
|
+
|
|
8
|
+
// ── dnd-kit mock ──────────────────────────────────────────────────────────────
|
|
9
|
+
// @dnd-kit/react's onDragEnd event uses source.initialIndex / source.index,
|
|
10
|
+
// not source.id vs target.id. We capture the callback here so tests can
|
|
11
|
+
// call it directly with the correct event shape.
|
|
12
|
+
|
|
13
|
+
let capturedDragEnd: ((event: {
|
|
14
|
+
canceled: boolean
|
|
15
|
+
operation: { source: { id: unknown; index: number; initialIndex: number } | null }
|
|
16
|
+
}) => void) | undefined
|
|
17
|
+
|
|
18
|
+
vi.mock('@dnd-kit/react', () => ({
|
|
19
|
+
DragDropProvider: ({
|
|
20
|
+
children,
|
|
21
|
+
onDragEnd,
|
|
22
|
+
}: {
|
|
23
|
+
children: React.ReactNode
|
|
24
|
+
onDragEnd: typeof capturedDragEnd
|
|
25
|
+
}) => {
|
|
26
|
+
capturedDragEnd = onDragEnd
|
|
27
|
+
return React.createElement(React.Fragment, null, children)
|
|
28
|
+
},
|
|
29
|
+
}))
|
|
30
|
+
|
|
31
|
+
vi.mock('@dnd-kit/react/sortable', () => ({
|
|
32
|
+
useSortable: () => ({
|
|
33
|
+
ref: () => undefined,
|
|
34
|
+
handleRef: () => undefined,
|
|
35
|
+
isDragging: false,
|
|
36
|
+
}),
|
|
37
|
+
}))
|
|
38
|
+
|
|
39
|
+
// ── Fixtures ──────────────────────────────────────────────────────────────────
|
|
40
|
+
// Mirrors the mock data in ScenarioQueue.stories.tsx
|
|
41
|
+
|
|
42
|
+
const MOCK_QUEUE: Scenario[] = [
|
|
43
|
+
{
|
|
44
|
+
id: 'ux-research',
|
|
45
|
+
title: 'UX Research & Design Interview',
|
|
46
|
+
category: 'Design',
|
|
47
|
+
difficulty: 'beginner',
|
|
48
|
+
duration: '10-15 min',
|
|
49
|
+
status: 'queued',
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
id: 'biz-analysis',
|
|
53
|
+
title: 'Business Analysis ROI Design Presentation',
|
|
54
|
+
category: 'Business',
|
|
55
|
+
difficulty: 'intermediate',
|
|
56
|
+
duration: '15-25 min',
|
|
57
|
+
status: 'queued',
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
id: 'product-redesign',
|
|
61
|
+
title: 'Product Redesign Challenge',
|
|
62
|
+
category: 'Product',
|
|
63
|
+
difficulty: 'advanced',
|
|
64
|
+
duration: '25-35 min',
|
|
65
|
+
status: 'queued',
|
|
66
|
+
},
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
const MOCK_COMPLETED: Scenario[] = [
|
|
70
|
+
{
|
|
71
|
+
id: 'stakeholder-mgmt',
|
|
72
|
+
title: 'Stakeholder Management Scenario',
|
|
73
|
+
category: 'Leadership',
|
|
74
|
+
difficulty: 'intermediate',
|
|
75
|
+
duration: '20-30 min',
|
|
76
|
+
status: 'completed',
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
id: 'agile-sprint',
|
|
80
|
+
title: 'Agile Sprint Planning Session',
|
|
81
|
+
category: 'Process',
|
|
82
|
+
difficulty: 'beginner',
|
|
83
|
+
duration: '10-15 min',
|
|
84
|
+
status: 'completed',
|
|
85
|
+
},
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
const ALL_SCENARIOS = [...MOCK_QUEUE, ...MOCK_COMPLETED]
|
|
89
|
+
|
|
90
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
function simulateDrag(initialIndex: number, newIndex: number) {
|
|
93
|
+
act(() => {
|
|
94
|
+
capturedDragEnd?.({
|
|
95
|
+
canceled: false,
|
|
96
|
+
operation: {
|
|
97
|
+
source: { id: MOCK_QUEUE[initialIndex]?.id ?? 'item', initialIndex, index: newIndex },
|
|
98
|
+
},
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── Tests ─────────────────────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
describe('ScenarioQueue', () => {
|
|
106
|
+
beforeEach(() => {
|
|
107
|
+
capturedDragEnd = undefined
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
// ── Rendering ───────────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
describe('Rendering', () => {
|
|
113
|
+
it('queue tab is the default active tab', () => {
|
|
114
|
+
render(<ScenarioQueue scenarios={ALL_SCENARIOS} />)
|
|
115
|
+
|
|
116
|
+
const queueTab = screen.getByRole('tab', { name: 'In Queue' })
|
|
117
|
+
expect(queueTab).toHaveAttribute('aria-selected', 'true')
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('queue tab renders 3 scenario cards by default', () => {
|
|
121
|
+
render(<ScenarioQueue scenarios={ALL_SCENARIOS} />)
|
|
122
|
+
|
|
123
|
+
expect(screen.getByText('UX Research & Design Interview')).toBeInTheDocument()
|
|
124
|
+
expect(
|
|
125
|
+
screen.getByText('Business Analysis ROI Design Presentation'),
|
|
126
|
+
).toBeInTheDocument()
|
|
127
|
+
expect(screen.getByText('Product Redesign Challenge')).toBeInTheDocument()
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('switching to Completed tab makes 2 completed cards visible', async () => {
|
|
131
|
+
const user = userEvent.setup()
|
|
132
|
+
render(<ScenarioQueue scenarios={ALL_SCENARIOS} />)
|
|
133
|
+
|
|
134
|
+
await user.click(screen.getByRole('tab', { name: 'Completed' }))
|
|
135
|
+
|
|
136
|
+
expect(screen.getByText('Stakeholder Management Scenario')).toBeVisible()
|
|
137
|
+
expect(screen.getByText('Agile Sprint Planning Session')).toBeVisible()
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('header count reflects total number of scenarios', () => {
|
|
141
|
+
render(<ScenarioQueue scenarios={ALL_SCENARIOS} />)
|
|
142
|
+
expect(screen.getByText('5 scenarios')).toBeInTheDocument()
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('queue tab cards show drag handle, position badge, title, difficulty, duration', () => {
|
|
146
|
+
render(<ScenarioQueue scenarios={MOCK_QUEUE} />)
|
|
147
|
+
|
|
148
|
+
// Drag handles — one per queued card
|
|
149
|
+
expect(screen.getAllByLabelText('Drag to reorder')).toHaveLength(3)
|
|
150
|
+
|
|
151
|
+
// Position badges
|
|
152
|
+
expect(screen.getByLabelText('Position 1')).toBeInTheDocument()
|
|
153
|
+
expect(screen.getByLabelText('Position 2')).toBeInTheDocument()
|
|
154
|
+
expect(screen.getByLabelText('Position 3')).toBeInTheDocument()
|
|
155
|
+
|
|
156
|
+
// Titles, difficulty labels, durations
|
|
157
|
+
expect(screen.getByText('UX Research & Design Interview')).toBeInTheDocument()
|
|
158
|
+
expect(screen.getByText('Beginner')).toBeInTheDocument()
|
|
159
|
+
expect(screen.getByText('10-15 min')).toBeInTheDocument()
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('completed tab cards show title, difficulty, duration and re-queue toggle — no drag handle, no position badge', async () => {
|
|
163
|
+
const user = userEvent.setup()
|
|
164
|
+
// Render only completed — ensures zero drag handles exist anywhere in DOM
|
|
165
|
+
render(<ScenarioQueue scenarios={MOCK_COMPLETED} />)
|
|
166
|
+
|
|
167
|
+
await user.click(screen.getByRole('tab', { name: 'Completed' }))
|
|
168
|
+
|
|
169
|
+
// Cards are present
|
|
170
|
+
expect(screen.getByText('Stakeholder Management Scenario')).toBeInTheDocument()
|
|
171
|
+
expect(screen.getByText('Agile Sprint Planning Session')).toBeInTheDocument()
|
|
172
|
+
|
|
173
|
+
// Re-queue toggles present
|
|
174
|
+
expect(
|
|
175
|
+
screen.getByLabelText('Re-queue Stakeholder Management Scenario'),
|
|
176
|
+
).toBeInTheDocument()
|
|
177
|
+
expect(
|
|
178
|
+
screen.getByLabelText('Re-queue Agile Sprint Planning Session'),
|
|
179
|
+
).toBeInTheDocument()
|
|
180
|
+
|
|
181
|
+
// No drag handles or position badges anywhere
|
|
182
|
+
expect(screen.queryByLabelText('Drag to reorder')).not.toBeInTheDocument()
|
|
183
|
+
expect(screen.queryByLabelText(/^Position \d/)).not.toBeInTheDocument()
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('empty queue tab shows empty state message', () => {
|
|
187
|
+
render(<ScenarioQueue scenarios={MOCK_COMPLETED} />)
|
|
188
|
+
expect(screen.getByText('No scenarios in queue')).toBeInTheDocument()
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('empty completed tab shows empty state message', async () => {
|
|
192
|
+
const user = userEvent.setup()
|
|
193
|
+
render(<ScenarioQueue scenarios={MOCK_QUEUE} />)
|
|
194
|
+
|
|
195
|
+
await user.click(screen.getByRole('tab', { name: 'Completed' }))
|
|
196
|
+
|
|
197
|
+
expect(screen.getByText('No completed scenarios')).toBeInTheDocument()
|
|
198
|
+
})
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
// ── Position Numbering & Active Card Styling ────────────────────────────────
|
|
202
|
+
|
|
203
|
+
describe('Position Numbering', () => {
|
|
204
|
+
it('shows sequential position numbers 1, 2, 3 matching array order', () => {
|
|
205
|
+
render(<ScenarioQueue scenarios={MOCK_QUEUE} />)
|
|
206
|
+
|
|
207
|
+
expect(screen.getByLabelText('Position 1')).toHaveTextContent('1')
|
|
208
|
+
expect(screen.getByLabelText('Position 2')).toHaveTextContent('2')
|
|
209
|
+
expect(screen.getByLabelText('Position 3')).toHaveTextContent('3')
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('position badges are in the correct DOM order (1 before 2 before 3)', () => {
|
|
213
|
+
const { container } = render(<ScenarioQueue scenarios={MOCK_QUEUE} />)
|
|
214
|
+
|
|
215
|
+
const badges = container.querySelectorAll('[aria-label^="Position"]')
|
|
216
|
+
expect(badges[0]).toHaveAccessibleName('Position 1')
|
|
217
|
+
expect(badges[1]).toHaveAccessibleName('Position 2')
|
|
218
|
+
expect(badges[2]).toHaveAccessibleName('Position 3')
|
|
219
|
+
})
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
// ── Difficulty Badge Attributes ─────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
describe('Difficulty Badge Attributes', () => {
|
|
225
|
+
it('beginner card renders data-difficulty="beginner" on its badge', () => {
|
|
226
|
+
const { container } = render(<ScenarioQueue scenarios={MOCK_QUEUE} />)
|
|
227
|
+
|
|
228
|
+
// At least one element (difficultyBadge) must carry data-difficulty="beginner"
|
|
229
|
+
expect(
|
|
230
|
+
container.querySelector('[data-difficulty="beginner"]'),
|
|
231
|
+
).toBeInTheDocument()
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
it('intermediate card renders data-difficulty="intermediate" on its badge', () => {
|
|
235
|
+
const { container } = render(<ScenarioQueue scenarios={MOCK_QUEUE} />)
|
|
236
|
+
|
|
237
|
+
expect(
|
|
238
|
+
container.querySelector('[data-difficulty="intermediate"]'),
|
|
239
|
+
).toBeInTheDocument()
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
it('advanced card renders data-difficulty="advanced" on its badge', () => {
|
|
243
|
+
const { container } = render(<ScenarioQueue scenarios={MOCK_QUEUE} />)
|
|
244
|
+
|
|
245
|
+
expect(
|
|
246
|
+
container.querySelector('[data-difficulty="advanced"]'),
|
|
247
|
+
).toBeInTheDocument()
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
it('difficulty label text matches the difficulty value', () => {
|
|
251
|
+
render(<ScenarioQueue scenarios={MOCK_QUEUE} />)
|
|
252
|
+
|
|
253
|
+
expect(screen.getByText('Beginner')).toBeInTheDocument()
|
|
254
|
+
expect(screen.getByText('Intermediate')).toBeInTheDocument()
|
|
255
|
+
expect(screen.getByText('Advanced')).toBeInTheDocument()
|
|
256
|
+
})
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
// ── Re-queue Flow ───────────────────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
describe('Re-queue Flow', () => {
|
|
262
|
+
it('toggling the re-queue switch calls onRequeue with the scenario ID', async () => {
|
|
263
|
+
const user = userEvent.setup()
|
|
264
|
+
const onRequeue = vi.fn()
|
|
265
|
+
render(<ScenarioQueue scenarios={ALL_SCENARIOS} onRequeue={onRequeue} />)
|
|
266
|
+
|
|
267
|
+
await user.click(screen.getByRole('tab', { name: 'Completed' }))
|
|
268
|
+
|
|
269
|
+
// Switch starts checked (defaultChecked). Click to uncheck → triggers re-queue.
|
|
270
|
+
const switchEl = screen.getByLabelText('Re-queue Stakeholder Management Scenario')
|
|
271
|
+
await user.click(switchEl)
|
|
272
|
+
|
|
273
|
+
expect(onRequeue).toHaveBeenCalledWith('stakeholder-mgmt')
|
|
274
|
+
expect(onRequeue).toHaveBeenCalledTimes(1)
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
it('re-queued card with wasRequeued=true shows Repeat badge in queue tab', () => {
|
|
278
|
+
const requeued: Scenario = {
|
|
279
|
+
...MOCK_COMPLETED[0],
|
|
280
|
+
status: 'queued',
|
|
281
|
+
wasRequeued: true,
|
|
282
|
+
}
|
|
283
|
+
render(<ScenarioQueue scenarios={[...MOCK_QUEUE, requeued]} />)
|
|
284
|
+
|
|
285
|
+
expect(screen.getByText('Repeat')).toBeInTheDocument()
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
it('re-queued card appears as position 4 at end of queue', () => {
|
|
289
|
+
const requeued: Scenario = {
|
|
290
|
+
...MOCK_COMPLETED[0],
|
|
291
|
+
status: 'queued',
|
|
292
|
+
wasRequeued: true,
|
|
293
|
+
}
|
|
294
|
+
render(<ScenarioQueue scenarios={[...MOCK_QUEUE, requeued]} />)
|
|
295
|
+
|
|
296
|
+
expect(screen.getByLabelText('Position 4')).toHaveTextContent('4')
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
it('cards without wasRequeued flag do not show Repeat badge', () => {
|
|
300
|
+
render(<ScenarioQueue scenarios={MOCK_QUEUE} />)
|
|
301
|
+
expect(screen.queryByText('Repeat')).not.toBeInTheDocument()
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
it('only the re-queued card shows the Repeat badge, not other queue cards', () => {
|
|
305
|
+
const requeued: Scenario = {
|
|
306
|
+
...MOCK_COMPLETED[0],
|
|
307
|
+
status: 'queued',
|
|
308
|
+
wasRequeued: true,
|
|
309
|
+
}
|
|
310
|
+
render(<ScenarioQueue scenarios={[...MOCK_QUEUE, requeued]} />)
|
|
311
|
+
|
|
312
|
+
// Exactly one Repeat badge
|
|
313
|
+
expect(screen.getAllByText('Repeat')).toHaveLength(1)
|
|
314
|
+
})
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
// ── Drag-Drop Reorder ───────────────────────────────────────────────────────
|
|
318
|
+
|
|
319
|
+
describe('Drag-Drop Reorder', () => {
|
|
320
|
+
it('simulated reorder calls onReorder with new ID order', () => {
|
|
321
|
+
const onReorder = vi.fn()
|
|
322
|
+
render(<ScenarioQueue scenarios={MOCK_QUEUE} onReorder={onReorder} />)
|
|
323
|
+
|
|
324
|
+
// Move card at index 0 (ux-research) to index 1
|
|
325
|
+
simulateDrag(0, 1)
|
|
326
|
+
|
|
327
|
+
expect(onReorder).toHaveBeenCalledWith([
|
|
328
|
+
'biz-analysis',
|
|
329
|
+
'ux-research',
|
|
330
|
+
'product-redesign',
|
|
331
|
+
])
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
it('position badge numbers update to reflect new array order after reorder', () => {
|
|
335
|
+
render(<ScenarioQueue scenarios={MOCK_QUEUE} />)
|
|
336
|
+
|
|
337
|
+
// Move card 0 (ux-research) to index 2 (last position)
|
|
338
|
+
simulateDrag(0, 2)
|
|
339
|
+
|
|
340
|
+
// The 3 positions should still exist (numbers 1, 2, 3)
|
|
341
|
+
expect(screen.getByLabelText('Position 1')).toHaveTextContent('1')
|
|
342
|
+
expect(screen.getByLabelText('Position 2')).toHaveTextContent('2')
|
|
343
|
+
expect(screen.getByLabelText('Position 3')).toHaveTextContent('3')
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
it('after reorder, position badges are in the correct DOM order', () => {
|
|
347
|
+
const { container } = render(<ScenarioQueue scenarios={MOCK_QUEUE} />)
|
|
348
|
+
|
|
349
|
+
simulateDrag(0, 2)
|
|
350
|
+
|
|
351
|
+
// The DOM order of position badges should be 1 → 2 → 3 top to bottom
|
|
352
|
+
const badges = container.querySelectorAll('[aria-label^="Position"]')
|
|
353
|
+
expect(badges[0]).toHaveAccessibleName('Position 1')
|
|
354
|
+
expect(badges[1]).toHaveAccessibleName('Position 2')
|
|
355
|
+
expect(badges[2]).toHaveAccessibleName('Position 3')
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
it('canceled drag does not update positions or call onReorder', () => {
|
|
359
|
+
const onReorder = vi.fn()
|
|
360
|
+
render(<ScenarioQueue scenarios={MOCK_QUEUE} onReorder={onReorder} />)
|
|
361
|
+
|
|
362
|
+
act(() => {
|
|
363
|
+
capturedDragEnd?.({
|
|
364
|
+
canceled: true,
|
|
365
|
+
operation: {
|
|
366
|
+
source: { id: 'ux-research', initialIndex: 0, index: 1 },
|
|
367
|
+
},
|
|
368
|
+
})
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
expect(onReorder).not.toHaveBeenCalled()
|
|
372
|
+
// Positions unchanged
|
|
373
|
+
expect(screen.getByLabelText('Position 1')).toHaveTextContent('1')
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
it('drag to same index does not update positions or call onReorder', () => {
|
|
377
|
+
const onReorder = vi.fn()
|
|
378
|
+
render(<ScenarioQueue scenarios={MOCK_QUEUE} onReorder={onReorder} />)
|
|
379
|
+
|
|
380
|
+
simulateDrag(1, 1) // same index — no-op
|
|
381
|
+
|
|
382
|
+
expect(onReorder).not.toHaveBeenCalled()
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
it('drag with null source does not throw or call onReorder', () => {
|
|
386
|
+
const onReorder = vi.fn()
|
|
387
|
+
render(<ScenarioQueue scenarios={MOCK_QUEUE} onReorder={onReorder} />)
|
|
388
|
+
|
|
389
|
+
expect(() => {
|
|
390
|
+
act(() => {
|
|
391
|
+
capturedDragEnd?.({ canceled: false, operation: { source: null } })
|
|
392
|
+
})
|
|
393
|
+
}).not.toThrow()
|
|
394
|
+
|
|
395
|
+
expect(onReorder).not.toHaveBeenCalled()
|
|
396
|
+
})
|
|
397
|
+
})
|
|
398
|
+
})
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
import { DragDropProvider } from '@dnd-kit/react'
|
|
3
|
+
import { useState, useEffect, type ComponentProps } from 'react'
|
|
4
|
+
import { css } from 'styled-system/css'
|
|
5
|
+
import { Box, Center } from 'styled-system/jsx'
|
|
6
|
+
import { scenarioQueue } from 'styled-system/recipes'
|
|
7
|
+
import { Button } from '../Button'
|
|
8
|
+
import * as Tabs from '../Tabs'
|
|
9
|
+
import { AddScenarioDialog } from './AddScenarioDialog'
|
|
10
|
+
import { ScenarioCard } from './ScenarioCard'
|
|
11
|
+
import { ScenarioCardDraggable } from './ScenarioCardDraggable'
|
|
12
|
+
import type { ScenarioQueueProps, Scenario } from './types'
|
|
13
|
+
|
|
14
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
function arrayMove<T>(array: T[], from: number, to: number): T[] {
|
|
17
|
+
const next = [...array]
|
|
18
|
+
const [item] = next.splice(from, 1)
|
|
19
|
+
next.splice(to, 0, item)
|
|
20
|
+
return next
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ── Component ─────────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
export function ScenarioQueue({
|
|
26
|
+
scenarios,
|
|
27
|
+
onReorder,
|
|
28
|
+
onRequeue,
|
|
29
|
+
onRemove: _onRemove,
|
|
30
|
+
renderAddScenarioContent,
|
|
31
|
+
onBrowseMore,
|
|
32
|
+
onBuildCustom,
|
|
33
|
+
}: ScenarioQueueProps) {
|
|
34
|
+
const queuedRaw = scenarios.filter((s) => s.status === 'queued')
|
|
35
|
+
const completed = scenarios.filter((s) => s.status === 'completed')
|
|
36
|
+
|
|
37
|
+
// Local ordering state — mirrors queuedRaw but supports optimistic drag reorder
|
|
38
|
+
const [localQueue, setLocalQueue] = useState<Scenario[]>(queuedRaw)
|
|
39
|
+
|
|
40
|
+
// Sync when the canonical scenarios list changes externally
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
setLocalQueue(queuedRaw)
|
|
43
|
+
}, [scenarios])
|
|
44
|
+
|
|
45
|
+
const [dialogOpen, setDialogOpen] = useState(false)
|
|
46
|
+
const totalCount = localQueue.length + completed.length
|
|
47
|
+
|
|
48
|
+
// useSortable adds index + initialIndex to the draggable at runtime; cast here
|
|
49
|
+
// since @dnd-kit/react's static Draggable<Data> type doesn't declare them.
|
|
50
|
+
type SortableSource = { index: number; initialIndex: number } & Record<string, unknown>
|
|
51
|
+
|
|
52
|
+
const handleDragEnd: NonNullable<ComponentProps<typeof DragDropProvider>['onDragEnd']> = (event) => {
|
|
53
|
+
if (event.canceled) return
|
|
54
|
+
const source = event.operation?.source as SortableSource | null
|
|
55
|
+
if (!source) return
|
|
56
|
+
|
|
57
|
+
const oldIndex = source.initialIndex
|
|
58
|
+
const newIndex = source.index
|
|
59
|
+
|
|
60
|
+
if (oldIndex === newIndex) return
|
|
61
|
+
|
|
62
|
+
const next = arrayMove(localQueue, oldIndex, newIndex)
|
|
63
|
+
setLocalQueue(next)
|
|
64
|
+
onReorder?.(next.map((s) => s.id))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const styles = scenarioQueue()
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<Box className={styles.root}>
|
|
71
|
+
{/* Header */}
|
|
72
|
+
<Box className={styles.header}>
|
|
73
|
+
<Box className={styles.title}>Scenario Queue</Box>
|
|
74
|
+
<Box className={styles.count}>
|
|
75
|
+
{totalCount} scenario{totalCount !== 1 ? 's' : ''}
|
|
76
|
+
</Box>
|
|
77
|
+
</Box>
|
|
78
|
+
|
|
79
|
+
{/* Tabs.Root gets tabsInner directly — no wrapper div needed.
|
|
80
|
+
flex:1 + minHeight:0 allows content panels to scroll independently
|
|
81
|
+
without the panel expanding indefinitely. */}
|
|
82
|
+
<Tabs.Root
|
|
83
|
+
defaultValue="queue"
|
|
84
|
+
variant="line"
|
|
85
|
+
size="md"
|
|
86
|
+
colorPalette="primary"
|
|
87
|
+
className={styles.tabsInner}
|
|
88
|
+
>
|
|
89
|
+
<Box className={styles.tabList}>
|
|
90
|
+
<Tabs.List className={css({ borderBottomWidth: '0' })}>
|
|
91
|
+
<Tabs.Trigger value="queue">In Queue</Tabs.Trigger>
|
|
92
|
+
<Tabs.Trigger value="completed">Completed</Tabs.Trigger>
|
|
93
|
+
<Tabs.Indicator />
|
|
94
|
+
</Tabs.List>
|
|
95
|
+
</Box>
|
|
96
|
+
|
|
97
|
+
{/* In Queue tab */}
|
|
98
|
+
<Tabs.Content value="queue" className={styles.tabsContent}>
|
|
99
|
+
{localQueue.length === 0 ? (
|
|
100
|
+
<Center className={styles.emptyState}>No scenarios in queue</Center>
|
|
101
|
+
) : (
|
|
102
|
+
<DragDropProvider onDragEnd={handleDragEnd}>
|
|
103
|
+
<Box className={styles.scrollArea}>
|
|
104
|
+
{localQueue.map((scenario, index) => (
|
|
105
|
+
<ScenarioCardDraggable
|
|
106
|
+
key={scenario.id}
|
|
107
|
+
scenario={scenario}
|
|
108
|
+
index={index}
|
|
109
|
+
position={index + 1}
|
|
110
|
+
isActive={index === 0}
|
|
111
|
+
isRepeat={scenario.wasRequeued ?? false}
|
|
112
|
+
/>
|
|
113
|
+
))}
|
|
114
|
+
</Box>
|
|
115
|
+
</DragDropProvider>
|
|
116
|
+
)}
|
|
117
|
+
</Tabs.Content>
|
|
118
|
+
|
|
119
|
+
{/* Completed tab */}
|
|
120
|
+
<Tabs.Content value="completed" className={styles.tabsContent}>
|
|
121
|
+
{completed.length === 0 ? (
|
|
122
|
+
<Center className={styles.emptyState}>No completed scenarios</Center>
|
|
123
|
+
) : (
|
|
124
|
+
<Box className={styles.scrollArea}>
|
|
125
|
+
{completed.map((scenario, index) => (
|
|
126
|
+
<ScenarioCard
|
|
127
|
+
key={scenario.id}
|
|
128
|
+
scenario={scenario}
|
|
129
|
+
position={index + 1}
|
|
130
|
+
isActive={false}
|
|
131
|
+
showRequeueSwitch
|
|
132
|
+
onRequeue={onRequeue}
|
|
133
|
+
/>
|
|
134
|
+
))}
|
|
135
|
+
</Box>
|
|
136
|
+
)}
|
|
137
|
+
</Tabs.Content>
|
|
138
|
+
</Tabs.Root>
|
|
139
|
+
|
|
140
|
+
{/* Pinned "Add Scenario" button — always visible, never scrolls */}
|
|
141
|
+
<Box className={styles.addButtonArea}>
|
|
142
|
+
<Button
|
|
143
|
+
variant="outline"
|
|
144
|
+
colorPalette="primary"
|
|
145
|
+
size="md"
|
|
146
|
+
className={styles.addButton}
|
|
147
|
+
onClick={() => setDialogOpen(true)}
|
|
148
|
+
>
|
|
149
|
+
+ Add Scenario
|
|
150
|
+
</Button>
|
|
151
|
+
</Box>
|
|
152
|
+
|
|
153
|
+
<AddScenarioDialog
|
|
154
|
+
open={dialogOpen}
|
|
155
|
+
onClose={() => setDialogOpen(false)}
|
|
156
|
+
renderContent={renderAddScenarioContent}
|
|
157
|
+
onBrowseMore={onBrowseMore}
|
|
158
|
+
onBuildCustom={onBuildCustom}
|
|
159
|
+
/>
|
|
160
|
+
</Box>
|
|
161
|
+
)
|
|
162
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { ScenarioQueue } from './ScenarioQueue'
|
|
2
|
+
export { ScenarioCard } from './ScenarioCard'
|
|
3
|
+
export { AddScenarioDialog } from './AddScenarioDialog'
|
|
4
|
+
export type {
|
|
5
|
+
ScenarioQueueProps,
|
|
6
|
+
ScenarioCardProps,
|
|
7
|
+
Scenario,
|
|
8
|
+
Difficulty,
|
|
9
|
+
ScenarioStatus,
|
|
10
|
+
} from './types'
|
|
11
|
+
export { difficultyColorMap, difficultyLabel } from './types'
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
// ──────────────────────────────────────────────
|
|
4
|
+
// Data Types
|
|
5
|
+
// ──────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
export type Difficulty = 'beginner' | 'intermediate' | 'advanced'
|
|
8
|
+
|
|
9
|
+
export type ScenarioStatus = 'queued' | 'completed'
|
|
10
|
+
|
|
11
|
+
export interface Scenario {
|
|
12
|
+
id: string
|
|
13
|
+
title: string
|
|
14
|
+
category: string
|
|
15
|
+
difficulty: Difficulty
|
|
16
|
+
/** Display string, e.g. "10-15 min" */
|
|
17
|
+
duration: string
|
|
18
|
+
status: ScenarioStatus
|
|
19
|
+
/** True when this card was previously completed and re-queued by the user */
|
|
20
|
+
wasRequeued?: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ──────────────────────────────────────────────
|
|
24
|
+
// Difficulty → Badge colorPalette mapping
|
|
25
|
+
// ──────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
export const difficultyColorMap: Record<Difficulty, 'primary' | 'secondary' | 'tertiary'> = {
|
|
28
|
+
beginner: 'primary',
|
|
29
|
+
intermediate: 'secondary',
|
|
30
|
+
advanced: 'tertiary',
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const difficultyLabel: Record<Difficulty, string> = {
|
|
34
|
+
beginner: 'Beginner',
|
|
35
|
+
intermediate: 'Intermediate',
|
|
36
|
+
advanced: 'Advanced',
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ──────────────────────────────────────────────
|
|
40
|
+
// Component Props
|
|
41
|
+
// ──────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
export interface ScenarioQueueProps {
|
|
44
|
+
/** All scenarios — the component splits them by status internally */
|
|
45
|
+
scenarios: Scenario[]
|
|
46
|
+
|
|
47
|
+
/** Called when user reorders cards in the "In Queue" tab */
|
|
48
|
+
onReorder?: (reorderedIds: string[]) => void
|
|
49
|
+
|
|
50
|
+
/** Called when user toggles a completed scenario back to the queue */
|
|
51
|
+
onRequeue?: (scenarioId: string) => void
|
|
52
|
+
|
|
53
|
+
/** Called when user removes a scenario from the queue */
|
|
54
|
+
onRemove?: (scenarioId: string) => void
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Render prop / slot for the Add Scenario modal body.
|
|
58
|
+
* The design system provides the Dialog shell; the consumer (discourser.ai)
|
|
59
|
+
* provides the collection content.
|
|
60
|
+
*
|
|
61
|
+
* Receives `onClose` to allow the consumer to close the modal after selection.
|
|
62
|
+
*/
|
|
63
|
+
renderAddScenarioContent?: (props: { onClose: () => void }) => ReactNode
|
|
64
|
+
|
|
65
|
+
/** Called when user clicks "Browse More Scenarios" link inside the modal */
|
|
66
|
+
onBrowseMore?: () => void
|
|
67
|
+
|
|
68
|
+
/** Called when user clicks "Build Custom Scenario" link inside the modal */
|
|
69
|
+
onBuildCustom?: () => void
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface ScenarioCardProps {
|
|
73
|
+
scenario: Scenario
|
|
74
|
+
/** 1-based position number shown in the circle badge */
|
|
75
|
+
position: number
|
|
76
|
+
/** Whether this card is the active (first) scenario */
|
|
77
|
+
isActive: boolean
|
|
78
|
+
/** Show the re-queue switch (used in Completed tab) */
|
|
79
|
+
showRequeueSwitch?: boolean
|
|
80
|
+
/** Called when re-queue switch is toggled */
|
|
81
|
+
onRequeue?: (scenarioId: string) => void
|
|
82
|
+
/** Whether this is a repeat scenario */
|
|
83
|
+
isRepeat?: boolean
|
|
84
|
+
/** Visual state while the card is being dragged (applies opacity) */
|
|
85
|
+
isDragging?: boolean
|
|
86
|
+
}
|
package/src/components/index.ts
CHANGED
|
@@ -44,8 +44,27 @@ export * as Tooltip from './Tooltip';
|
|
|
44
44
|
// Utility Components (namespace pattern - may have multiple exports)
|
|
45
45
|
export * as CloseButton from './CloseButton';
|
|
46
46
|
export * as Icon from './Icon';
|
|
47
|
+
|
|
48
|
+
// Icons (individual icon components)
|
|
49
|
+
export { GripDotsVerticalIcon, type GripDotsVerticalIconProps } from './Icons/GripDotsVerticalIcon';
|
|
50
|
+
export { ClockIcon, type ClockIconProps } from './Icons/ClockIcon';
|
|
47
51
|
export * as AbsoluteCenter from './AbsoluteCenter';
|
|
48
52
|
export * as Group from './Group';
|
|
49
53
|
|
|
50
54
|
// Navigation & Progress Components
|
|
55
|
+
export * as Breadcrumb from './Breadcrumb';
|
|
51
56
|
export { Stepper, type StepperRootProps, type StepItem } from './Stepper';
|
|
57
|
+
|
|
58
|
+
// Composite / Feature Components
|
|
59
|
+
export {
|
|
60
|
+
ScenarioQueue,
|
|
61
|
+
ScenarioCard,
|
|
62
|
+
AddScenarioDialog,
|
|
63
|
+
difficultyColorMap,
|
|
64
|
+
difficultyLabel,
|
|
65
|
+
type ScenarioQueueProps,
|
|
66
|
+
type ScenarioCardProps,
|
|
67
|
+
type Scenario,
|
|
68
|
+
type Difficulty,
|
|
69
|
+
type ScenarioStatus,
|
|
70
|
+
} from './ScenarioQueue';
|