@balby/booking-search 1.0.0
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/LICENSE +21 -0
- package/README.md +254 -0
- package/dist/index.css +1668 -0
- package/dist/index.js +12681 -0
- package/package.json +84 -0
- package/src/components/booking-search/date-range-picker.tsx +240 -0
- package/src/components/booking-search/guest-selector.tsx +181 -0
- package/src/components/booking-search/index.tsx +199 -0
- package/src/components/booking-search/location-combobox.tsx +93 -0
- package/src/components/booking-search/tests/date-range-picker.test.tsx +308 -0
- package/src/components/booking-search/tests/guest-selector.test.tsx +358 -0
- package/src/components/booking-search/tests/index.test.tsx +424 -0
- package/src/components/booking-search/tests/location-combobox.test.tsx +263 -0
- package/src/components/booking-search/ui/command.tsx +100 -0
- package/src/components/booking-search/ui/dialog.tsx +88 -0
- package/src/components/booking-search/ui/popover.tsx +28 -0
- package/src/demo.tsx +198 -0
- package/src/index.ts +27 -0
- package/src/lib/utils.ts +6 -0
- package/src/styles/globals.css +158 -0
- package/src/styles/output.css +4 -0
- package/src/styles/output.css.map +1 -0
- package/src/types/booking.ts +155 -0
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
import { test, expect, describe, beforeEach } from "bun:test"
|
|
2
|
+
import React from "react"
|
|
3
|
+
import { render, screen, fireEvent, waitFor } from "@testing-library/react"
|
|
4
|
+
import { BookingSearch } from "../index"
|
|
5
|
+
import type { SearchLocation, AvailabilityDay, BookingSearchPayload } from "../../../types/booking"
|
|
6
|
+
import { format, addDays } from "date-fns"
|
|
7
|
+
|
|
8
|
+
describe("BookingSearch", () => {
|
|
9
|
+
const today = new Date()
|
|
10
|
+
const tomorrow = addDays(today, 1)
|
|
11
|
+
const nextWeek = addDays(today, 7)
|
|
12
|
+
|
|
13
|
+
const mockLocations: SearchLocation[] = [
|
|
14
|
+
{ id: "1", name: "Rome", type: "City" },
|
|
15
|
+
{ id: "2", name: "Milan", type: "City" },
|
|
16
|
+
{ id: "3", name: "Venice", type: "City" },
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
const mockAvailability: AvailabilityDay[] = Array.from({ length: 30 }, (_, i) => {
|
|
20
|
+
const date = addDays(today, i)
|
|
21
|
+
return {
|
|
22
|
+
date: format(date, "yyyy-MM-dd"),
|
|
23
|
+
isAvailable: true,
|
|
24
|
+
price: 100 + i * 5,
|
|
25
|
+
}
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
const mockOnSearch = (params: BookingSearchPayload) => {}
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
// Mock window.innerWidth for desktop view
|
|
32
|
+
Object.defineProperty(window, "innerWidth", {
|
|
33
|
+
writable: true,
|
|
34
|
+
configurable: true,
|
|
35
|
+
value: 1024,
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test("renders all search components on desktop", () => {
|
|
40
|
+
render(
|
|
41
|
+
<BookingSearch
|
|
42
|
+
availability={mockAvailability}
|
|
43
|
+
locations={mockLocations}
|
|
44
|
+
onSearch={mockOnSearch}
|
|
45
|
+
/>
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
expect(screen.getByText("Destination")).toBeDefined()
|
|
49
|
+
expect(screen.getByText("Check-in - Check-out")).toBeDefined()
|
|
50
|
+
expect(screen.getByText("Guests")).toBeDefined()
|
|
51
|
+
expect(screen.getByRole("button", { name: /search/i })).toBeDefined()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test("displays default location when provided", () => {
|
|
55
|
+
const firstLocation = mockLocations[0]
|
|
56
|
+
if (!firstLocation) return
|
|
57
|
+
|
|
58
|
+
render(
|
|
59
|
+
<BookingSearch
|
|
60
|
+
availability={mockAvailability}
|
|
61
|
+
locations={mockLocations}
|
|
62
|
+
onSearch={mockOnSearch}
|
|
63
|
+
defaultValues={{
|
|
64
|
+
location: firstLocation,
|
|
65
|
+
checkIn: null,
|
|
66
|
+
checkOut: null,
|
|
67
|
+
adults: 2,
|
|
68
|
+
children: 0,
|
|
69
|
+
}}
|
|
70
|
+
/>
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
expect(screen.getByText("Rome")).toBeDefined()
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test("displays default dates when provided", () => {
|
|
77
|
+
render(
|
|
78
|
+
<BookingSearch
|
|
79
|
+
availability={mockAvailability}
|
|
80
|
+
locations={mockLocations}
|
|
81
|
+
onSearch={mockOnSearch}
|
|
82
|
+
defaultValues={{
|
|
83
|
+
location: null,
|
|
84
|
+
checkIn: today,
|
|
85
|
+
checkOut: nextWeek,
|
|
86
|
+
adults: 2,
|
|
87
|
+
children: 0,
|
|
88
|
+
}}
|
|
89
|
+
/>
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
const dateText = screen.getByText(/\d{2} \w{3} - \d{2} \w{3}/)
|
|
93
|
+
expect(dateText).toBeDefined()
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test("displays default guest count when provided", () => {
|
|
97
|
+
render(
|
|
98
|
+
<BookingSearch
|
|
99
|
+
availability={mockAvailability}
|
|
100
|
+
locations={mockLocations}
|
|
101
|
+
onSearch={mockOnSearch}
|
|
102
|
+
defaultValues={{
|
|
103
|
+
location: null,
|
|
104
|
+
checkIn: null,
|
|
105
|
+
checkOut: null,
|
|
106
|
+
adults: 3,
|
|
107
|
+
children: 2,
|
|
108
|
+
}}
|
|
109
|
+
/>
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
expect(screen.getByText("3 adulti, 2 bambini")).toBeDefined()
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test("search button is disabled when location is not selected", () => {
|
|
116
|
+
render(
|
|
117
|
+
<BookingSearch
|
|
118
|
+
availability={mockAvailability}
|
|
119
|
+
locations={mockLocations}
|
|
120
|
+
onSearch={mockOnSearch}
|
|
121
|
+
/>
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
const searchButton = screen.getByRole("button", { name: /search/i })
|
|
125
|
+
expect(searchButton.hasAttribute("disabled")).toBe(true)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test("search button is disabled when dates are not selected", () => {
|
|
129
|
+
const firstLocation = mockLocations[0]
|
|
130
|
+
if (!firstLocation) return
|
|
131
|
+
|
|
132
|
+
render(
|
|
133
|
+
<BookingSearch
|
|
134
|
+
availability={mockAvailability}
|
|
135
|
+
locations={mockLocations}
|
|
136
|
+
onSearch={mockOnSearch}
|
|
137
|
+
defaultValues={{
|
|
138
|
+
location: firstLocation,
|
|
139
|
+
checkIn: null,
|
|
140
|
+
checkOut: null,
|
|
141
|
+
adults: 2,
|
|
142
|
+
children: 0,
|
|
143
|
+
}}
|
|
144
|
+
/>
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
const searchButton = screen.getByRole("button", { name: /search/i })
|
|
148
|
+
expect(searchButton.hasAttribute("disabled")).toBe(true)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
test("search button is enabled when location and dates are selected", () => {
|
|
152
|
+
const firstLocation = mockLocations[0]
|
|
153
|
+
if (!firstLocation) return
|
|
154
|
+
|
|
155
|
+
render(
|
|
156
|
+
<BookingSearch
|
|
157
|
+
availability={mockAvailability}
|
|
158
|
+
locations={mockLocations}
|
|
159
|
+
onSearch={mockOnSearch}
|
|
160
|
+
defaultValues={{
|
|
161
|
+
location: firstLocation,
|
|
162
|
+
checkIn: today,
|
|
163
|
+
checkOut: nextWeek,
|
|
164
|
+
adults: 2,
|
|
165
|
+
children: 0,
|
|
166
|
+
}}
|
|
167
|
+
/>
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
const searchButton = screen.getByRole("button", { name: /search/i })
|
|
171
|
+
expect(searchButton.hasAttribute("disabled")).toBe(false)
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
test("calls onSearch with correct parameters when search button is clicked", () => {
|
|
175
|
+
const firstLocation = mockLocations[0]
|
|
176
|
+
if (!firstLocation) return
|
|
177
|
+
|
|
178
|
+
let searchParams: BookingSearchPayload | null = null
|
|
179
|
+
const handleSearch = (params: BookingSearchPayload) => {
|
|
180
|
+
searchParams = params
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
render(
|
|
184
|
+
<BookingSearch
|
|
185
|
+
availability={mockAvailability}
|
|
186
|
+
locations={mockLocations}
|
|
187
|
+
onSearch={handleSearch}
|
|
188
|
+
defaultValues={{
|
|
189
|
+
location: firstLocation,
|
|
190
|
+
checkIn: today,
|
|
191
|
+
checkOut: nextWeek,
|
|
192
|
+
adults: 2,
|
|
193
|
+
children: 1,
|
|
194
|
+
}}
|
|
195
|
+
/>
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
const searchButton = screen.getByRole("button", { name: /search/i })
|
|
199
|
+
expect(searchButton).toBeDefined()
|
|
200
|
+
fireEvent.click(searchButton)
|
|
201
|
+
|
|
202
|
+
expect(searchParams).not.toBeNull()
|
|
203
|
+
expect(searchParams!.location?.id).toBe(firstLocation.id)
|
|
204
|
+
expect(searchParams!.adults).toBe(2)
|
|
205
|
+
expect(searchParams!.children).toBe(1)
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
test("uses custom search button text when provided", () => {
|
|
209
|
+
render(
|
|
210
|
+
<BookingSearch
|
|
211
|
+
availability={mockAvailability}
|
|
212
|
+
locations={mockLocations}
|
|
213
|
+
onSearch={mockOnSearch}
|
|
214
|
+
searchButtonText="Ricerca"
|
|
215
|
+
/>
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
expect(screen.getByText("Ricerca")).toBeDefined()
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
test("uses custom location placeholder when provided", () => {
|
|
222
|
+
render(
|
|
223
|
+
<BookingSearch
|
|
224
|
+
availability={mockAvailability}
|
|
225
|
+
locations={mockLocations}
|
|
226
|
+
onSearch={mockOnSearch}
|
|
227
|
+
locationPlaceholder="Custom placeholder"
|
|
228
|
+
/>
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
expect(screen.getByText("Custom placeholder")).toBeDefined()
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
test("applies custom className to container", () => {
|
|
235
|
+
const { container } = render(
|
|
236
|
+
<BookingSearch
|
|
237
|
+
availability={mockAvailability}
|
|
238
|
+
locations={mockLocations}
|
|
239
|
+
onSearch={mockOnSearch}
|
|
240
|
+
className="custom-search-class"
|
|
241
|
+
/>
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
const searchContainer = container.querySelector('[role="search"]')
|
|
245
|
+
expect(searchContainer?.classList.contains("custom-search-class")).toBe(true)
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
test("renders mobile view when window width is below 768px", () => {
|
|
249
|
+
// Mock mobile viewport
|
|
250
|
+
Object.defineProperty(window, "innerWidth", {
|
|
251
|
+
writable: true,
|
|
252
|
+
configurable: true,
|
|
253
|
+
value: 375,
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
// Trigger resize event
|
|
257
|
+
window.dispatchEvent(new Event("resize"))
|
|
258
|
+
|
|
259
|
+
render(
|
|
260
|
+
<BookingSearch
|
|
261
|
+
availability={mockAvailability}
|
|
262
|
+
locations={mockLocations}
|
|
263
|
+
onSearch={mockOnSearch}
|
|
264
|
+
/>
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
// In mobile view, a dialog trigger button should be rendered
|
|
268
|
+
expect(screen.getByText("Dove vuoi andare?")).toBeDefined()
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
test("integrates all sub-components correctly", async () => {
|
|
272
|
+
render(
|
|
273
|
+
<BookingSearch
|
|
274
|
+
availability={mockAvailability}
|
|
275
|
+
locations={mockLocations}
|
|
276
|
+
onSearch={mockOnSearch}
|
|
277
|
+
/>
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
// Test location selection
|
|
281
|
+
const locationButton = screen.getByRole("combobox")
|
|
282
|
+
expect(locationButton).toBeDefined()
|
|
283
|
+
|
|
284
|
+
// Test date picker
|
|
285
|
+
const dateButton = screen.getByRole("button", { name: "Select Date Range" })
|
|
286
|
+
expect(dateButton).toBeDefined()
|
|
287
|
+
|
|
288
|
+
// Test guest selector
|
|
289
|
+
const guestButton = screen.getByRole("button", { name: "Select guests" })
|
|
290
|
+
expect(guestButton).toBeDefined()
|
|
291
|
+
|
|
292
|
+
// Test search button
|
|
293
|
+
const searchButton = screen.getByRole("button", { name: /search/i })
|
|
294
|
+
expect(searchButton).toBeDefined()
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
test("passes minNights prop to DateRangePicker", () => {
|
|
298
|
+
render(
|
|
299
|
+
<BookingSearch
|
|
300
|
+
availability={mockAvailability}
|
|
301
|
+
locations={mockLocations}
|
|
302
|
+
onSearch={mockOnSearch}
|
|
303
|
+
minNights={3}
|
|
304
|
+
/>
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
// The component should render without errors
|
|
308
|
+
expect(screen.getByText("Check-in - Check-out")).toBeDefined()
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
test("passes maxAdults prop to GuestSelector", () => {
|
|
312
|
+
render(
|
|
313
|
+
<BookingSearch
|
|
314
|
+
availability={mockAvailability}
|
|
315
|
+
locations={mockLocations}
|
|
316
|
+
onSearch={mockOnSearch}
|
|
317
|
+
maxAdults={10}
|
|
318
|
+
/>
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
// The component should render without errors
|
|
322
|
+
expect(screen.getByText("Guests")).toBeDefined()
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
test("passes maxChildren prop to GuestSelector", () => {
|
|
326
|
+
render(
|
|
327
|
+
<BookingSearch
|
|
328
|
+
availability={mockAvailability}
|
|
329
|
+
locations={mockLocations}
|
|
330
|
+
onSearch={mockOnSearch}
|
|
331
|
+
maxChildren={5}
|
|
332
|
+
/>
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
// The component should render without errors
|
|
336
|
+
expect(screen.getByText("Guests")).toBeDefined()
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
test("updates state when location is changed", async () => {
|
|
340
|
+
let searchParams: BookingSearchPayload | null = null
|
|
341
|
+
const handleSearch = (params: BookingSearchPayload) => {
|
|
342
|
+
searchParams = params
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
render(
|
|
346
|
+
<BookingSearch
|
|
347
|
+
availability={mockAvailability}
|
|
348
|
+
locations={mockLocations}
|
|
349
|
+
onSearch={handleSearch}
|
|
350
|
+
defaultValues={{
|
|
351
|
+
location: null,
|
|
352
|
+
checkIn: today,
|
|
353
|
+
checkOut: nextWeek,
|
|
354
|
+
adults: 2,
|
|
355
|
+
children: 0,
|
|
356
|
+
}}
|
|
357
|
+
/>
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
// Initially search button should be disabled
|
|
361
|
+
const searchButton = screen.getByRole("button", { name: /search/i })
|
|
362
|
+
expect(searchButton.hasAttribute("disabled")).toBe(true)
|
|
363
|
+
|
|
364
|
+
// Open location combobox
|
|
365
|
+
const locationButton = screen.getByRole("combobox")
|
|
366
|
+
fireEvent.click(locationButton)
|
|
367
|
+
|
|
368
|
+
const locationItems = await waitFor(() => {
|
|
369
|
+
const items = screen.getAllByText("Rome")
|
|
370
|
+
expect(items.length).toBeGreaterThan(0)
|
|
371
|
+
return items
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
// Click on the location in the popover
|
|
375
|
+
const lastItem = locationItems[locationItems.length - 1]
|
|
376
|
+
if (lastItem) {
|
|
377
|
+
fireEvent.click(lastItem)
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Wait for state to update and get updated button reference
|
|
381
|
+
const updatedSearchButton = await waitFor(() => {
|
|
382
|
+
const btn = screen.getByRole("button", { name: /search/i })
|
|
383
|
+
expect(btn).toBeDefined()
|
|
384
|
+
expect(btn.hasAttribute("disabled")).toBe(false)
|
|
385
|
+
return btn
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
// Click search button
|
|
389
|
+
if (updatedSearchButton) {
|
|
390
|
+
fireEvent.click(updatedSearchButton)
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Verify search params include the selected location
|
|
394
|
+
const firstLocation = mockLocations[0]
|
|
395
|
+
await waitFor(() => {
|
|
396
|
+
expect(searchParams).not.toBeNull()
|
|
397
|
+
if (searchParams && firstLocation) {
|
|
398
|
+
expect(searchParams.location?.id).toBe(firstLocation.id)
|
|
399
|
+
}
|
|
400
|
+
})
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
test("has proper ARIA labels for accessibility", () => {
|
|
404
|
+
render(
|
|
405
|
+
<BookingSearch
|
|
406
|
+
availability={mockAvailability}
|
|
407
|
+
locations={mockLocations}
|
|
408
|
+
onSearch={mockOnSearch}
|
|
409
|
+
/>
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
const searchContainer = screen.getByRole("search", { name: "Booking search" })
|
|
413
|
+
expect(searchContainer).toBeDefined()
|
|
414
|
+
|
|
415
|
+
const locationButton = screen.getByLabelText("Select location")
|
|
416
|
+
expect(locationButton).toBeDefined()
|
|
417
|
+
|
|
418
|
+
const dateButton = screen.getByLabelText("Select Date Range")
|
|
419
|
+
expect(dateButton).toBeDefined()
|
|
420
|
+
|
|
421
|
+
const guestButton = screen.getByLabelText("Select guests")
|
|
422
|
+
expect(guestButton).toBeDefined()
|
|
423
|
+
})
|
|
424
|
+
})
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { test, expect, describe, beforeEach } from "bun:test"
|
|
2
|
+
import React from "react"
|
|
3
|
+
import { render, screen, fireEvent, waitFor } from "@testing-library/react"
|
|
4
|
+
import { LocationCombobox } from "../location-combobox"
|
|
5
|
+
import type { SearchLocation } from "../../../types/booking"
|
|
6
|
+
|
|
7
|
+
describe("LocationCombobox", () => {
|
|
8
|
+
const mockLocations: SearchLocation[] = [
|
|
9
|
+
{ id: "1", name: "Rome", type: "City" },
|
|
10
|
+
{ id: "2", name: "Milan", type: "City" },
|
|
11
|
+
{ id: "3", name: "Venice", type: "City" },
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
const mockOnChange = (location: SearchLocation | null) => {}
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
// Reset any state between tests if needed
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
test("renders with placeholder when no value is selected", () => {
|
|
21
|
+
render(
|
|
22
|
+
<LocationCombobox
|
|
23
|
+
locations={mockLocations}
|
|
24
|
+
value={null}
|
|
25
|
+
onChange={mockOnChange}
|
|
26
|
+
placeholder="Where do you want to go?"
|
|
27
|
+
/>
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
expect(screen.getByText("Where do you want to go?")).toBeDefined()
|
|
31
|
+
expect(screen.getByText("Destination")).toBeDefined()
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test("displays selected location", () => {
|
|
35
|
+
const firstLocation = mockLocations[0]
|
|
36
|
+
if (!firstLocation) return
|
|
37
|
+
|
|
38
|
+
render(
|
|
39
|
+
<LocationCombobox
|
|
40
|
+
locations={mockLocations}
|
|
41
|
+
value={firstLocation}
|
|
42
|
+
onChange={mockOnChange}
|
|
43
|
+
/>
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
expect(screen.getByText("Rome")).toBeDefined()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test("opens popover when button is clicked", async () => {
|
|
50
|
+
render(
|
|
51
|
+
<LocationCombobox
|
|
52
|
+
locations={mockLocations}
|
|
53
|
+
value={null}
|
|
54
|
+
onChange={mockOnChange}
|
|
55
|
+
/>
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
const button = screen.getByRole("combobox")
|
|
59
|
+
fireEvent.click(button)
|
|
60
|
+
|
|
61
|
+
await waitFor(() => {
|
|
62
|
+
expect(screen.getByPlaceholderText("Search a destination...")).toBeDefined()
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test("displays all locations in the popover", async () => {
|
|
67
|
+
render(
|
|
68
|
+
<LocationCombobox
|
|
69
|
+
locations={mockLocations}
|
|
70
|
+
value={null}
|
|
71
|
+
onChange={mockOnChange}
|
|
72
|
+
/>
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
const button = screen.getByRole("combobox")
|
|
76
|
+
fireEvent.click(button)
|
|
77
|
+
|
|
78
|
+
await waitFor(() => {
|
|
79
|
+
for (const location of mockLocations) {
|
|
80
|
+
expect(screen.getAllByText(location.name).length).toBeGreaterThan(0)
|
|
81
|
+
}
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test("calls onChange when a location is selected", async () => {
|
|
86
|
+
let selectedLocation: SearchLocation | null = null
|
|
87
|
+
const handleChange = (location: SearchLocation | null) => {
|
|
88
|
+
selectedLocation = location
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
render(
|
|
92
|
+
<LocationCombobox
|
|
93
|
+
locations={mockLocations}
|
|
94
|
+
value={null}
|
|
95
|
+
onChange={handleChange}
|
|
96
|
+
/>
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
const button = screen.getByRole("combobox")
|
|
100
|
+
fireEvent.click(button)
|
|
101
|
+
|
|
102
|
+
const locationItems = await waitFor(() => {
|
|
103
|
+
const items = screen.getAllByText("Rome")
|
|
104
|
+
expect(items.length).toBeGreaterThan(0)
|
|
105
|
+
return items
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
// Click on the location in the popover (not the button)
|
|
109
|
+
const lastItem = locationItems[locationItems.length - 1]
|
|
110
|
+
if (lastItem) {
|
|
111
|
+
fireEvent.click(lastItem)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const firstLocation = mockLocations[0]
|
|
115
|
+
await waitFor(() => {
|
|
116
|
+
expect(selectedLocation).not.toBeNull()
|
|
117
|
+
if (selectedLocation && firstLocation) {
|
|
118
|
+
expect(selectedLocation.id).toBe(firstLocation.id)
|
|
119
|
+
}
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
test("toggles selection when clicking on already selected location", async () => {
|
|
124
|
+
const firstLocation = mockLocations[0]
|
|
125
|
+
if (!firstLocation) return
|
|
126
|
+
|
|
127
|
+
let selectedLocation: SearchLocation | null = firstLocation
|
|
128
|
+
const handleChange = (location: SearchLocation | null) => {
|
|
129
|
+
selectedLocation = location
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
render(
|
|
133
|
+
<LocationCombobox
|
|
134
|
+
locations={mockLocations}
|
|
135
|
+
value={firstLocation}
|
|
136
|
+
onChange={handleChange}
|
|
137
|
+
/>
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
const button = screen.getByRole("combobox")
|
|
141
|
+
fireEvent.click(button)
|
|
142
|
+
|
|
143
|
+
const locationItems = await waitFor(() => {
|
|
144
|
+
const items = screen.getAllByText("Rome")
|
|
145
|
+
expect(items.length).toBeGreaterThan(0)
|
|
146
|
+
return items
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
const lastItem = locationItems[locationItems.length - 1]
|
|
150
|
+
if (lastItem) {
|
|
151
|
+
fireEvent.click(lastItem)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
await waitFor(() => {
|
|
155
|
+
expect(selectedLocation).toBe(null)
|
|
156
|
+
})
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
test("disables the button when disabled prop is true", () => {
|
|
160
|
+
render(
|
|
161
|
+
<LocationCombobox
|
|
162
|
+
locations={mockLocations}
|
|
163
|
+
value={null}
|
|
164
|
+
onChange={mockOnChange}
|
|
165
|
+
disabled={true}
|
|
166
|
+
/>
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
const button = screen.getByRole("combobox")
|
|
170
|
+
expect(button.hasAttribute("disabled")).toBe(true)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
test("shows location type when available", async () => {
|
|
174
|
+
render(
|
|
175
|
+
<LocationCombobox
|
|
176
|
+
locations={mockLocations}
|
|
177
|
+
value={null}
|
|
178
|
+
onChange={mockOnChange}
|
|
179
|
+
/>
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
const button = screen.getByRole("combobox")
|
|
183
|
+
fireEvent.click(button)
|
|
184
|
+
|
|
185
|
+
await waitFor(() => {
|
|
186
|
+
mockLocations.forEach((location) => {
|
|
187
|
+
if (location.type) {
|
|
188
|
+
expect(screen.getAllByText(location.type).length).toBeGreaterThan(0)
|
|
189
|
+
}
|
|
190
|
+
})
|
|
191
|
+
})
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
test("shows empty message when no locations match search", async () => {
|
|
195
|
+
render(
|
|
196
|
+
<LocationCombobox
|
|
197
|
+
locations={mockLocations}
|
|
198
|
+
value={null}
|
|
199
|
+
onChange={mockOnChange}
|
|
200
|
+
/>
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
const button = screen.getByRole("combobox")
|
|
204
|
+
fireEvent.click(button)
|
|
205
|
+
|
|
206
|
+
await waitFor(() => {
|
|
207
|
+
const searchInput = screen.getByPlaceholderText("Search a destination...")
|
|
208
|
+
fireEvent.change(searchInput, { target: { value: "Nonexistent City" } })
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
await waitFor(() => {
|
|
212
|
+
expect(screen.getByText("No location found.")).toBeDefined()
|
|
213
|
+
})
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
test("applies custom className", () => {
|
|
217
|
+
const { container } = render(
|
|
218
|
+
<LocationCombobox
|
|
219
|
+
locations={mockLocations}
|
|
220
|
+
value={null}
|
|
221
|
+
onChange={mockOnChange}
|
|
222
|
+
className="custom-class"
|
|
223
|
+
/>
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
const button = container.querySelector("button")
|
|
227
|
+
expect(button).toBeDefined()
|
|
228
|
+
expect(button?.classList.contains("custom-class")).toBe(true)
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
test("closes popover after selecting a location", async () => {
|
|
232
|
+
render(
|
|
233
|
+
<LocationCombobox
|
|
234
|
+
locations={mockLocations}
|
|
235
|
+
value={null}
|
|
236
|
+
onChange={mockOnChange}
|
|
237
|
+
/>
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
const button = screen.getByRole("combobox")
|
|
241
|
+
expect(button).toBeDefined()
|
|
242
|
+
fireEvent.click(button)
|
|
243
|
+
|
|
244
|
+
await waitFor(() => {
|
|
245
|
+
expect(screen.getByPlaceholderText("Search a destination...")).toBeDefined()
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
const locationItems = await waitFor(() => {
|
|
249
|
+
const items = screen.getAllByText("Milan")
|
|
250
|
+
expect(items.length).toBeGreaterThan(0)
|
|
251
|
+
return items
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
const lastItem = locationItems[locationItems.length - 1]
|
|
255
|
+
if (lastItem) {
|
|
256
|
+
fireEvent.click(lastItem)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
await waitFor(() => {
|
|
260
|
+
expect(screen.queryByPlaceholderText("Search a destination...")).toBeNull()
|
|
261
|
+
})
|
|
262
|
+
})
|
|
263
|
+
})
|