• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

decentraland / marketplace / 6468133945

10 Oct 2023 10:36AM UTC coverage: 40.778% (+0.7%) from 40.103%
6468133945

Pull #2030

github

juanmahidalgo
test: add Test for SearchBarDropdown
Pull Request #2030: Feat: new search bar component

2260 of 6951 branches covered (0.0%)

Branch coverage included in aggregate %.

215 of 215 new or added lines in 20 files covered. (100.0%)

4280 of 9087 relevant lines covered (47.1%)

18.0 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

83.49
/webapp/src/components/AssetTopbar/SearchBarDropdown/SearchBarDropdown.tsx
1
import { useCallback, useEffect, useRef, useState } from 'react'
2
import { v5 as uuidv5 } from 'uuid'
3
import { Button, Close, Icon } from 'decentraland-ui'
4
import { Tabs } from 'decentraland-ui/dist'
5
import { Item, NFTCategory } from '@dcl/schemas'
6
import { t } from 'decentraland-dapps/dist/modules/translation/utils'
7
import { getAnalytics } from 'decentraland-dapps/dist/modules/analytics/utils'
8
import * as events from '../../../utils/events'
9
import clock from '../../../images/clock.png'
10
import { catalogAPI } from '../../../modules/vendor/decentraland/catalog/api'
11
import { BuilderCollectionAttributes } from '../../../modules/vendor/decentraland/builder/types'
12
import { CreatorAccount } from '../../../modules/account/types'
13
import { builderAPI } from '../../../modules/vendor/decentraland/builder/api'
14
import SearchBarDropdownOptionSkeleton from './SearchBarDropdownOptionSkeleton/SearchBarDropdownOptionSkeleton'
15
import { SearchBarDropdownProps, SearchTab } from './SearchBarDropdown.types'
16
import CreatorResultItemRow from './CreatorResultRow/CreatorResultRow'
17
import CollectionResultRow from './CollectionResultRow/CollectionResultRow'
18
import CollectibleResultItemRow from './CollectibleResultItemRow/CollectibleResultItemRow'
19
import styles from './SearchBarDropdown.module.css'
20
import {
21
  COLLECTIBLE_DATA_TEST_ID,
22
  COLLECTION_ROW_DATA_TEST_ID,
23
  NO_RESULTS_DATA_TEST_ID,
24
  RECENT_SEARCHES_DATA_TEST_ID,
25
  SEE_ALL_COLLECTIBLES_DATA_TEST_ID
26
} from './constants'
27

28
type Results = Item[] | BuilderCollectionAttributes[]
29
type RecentSearch = CreatorAccount | BuilderCollectionAttributes | Item
30

31
function isCreatorRecentSearch(search: RecentSearch): search is CreatorAccount {
32
  return 'collections' in search
1✔
33
}
34

35
function isCollectionRecentSearch(
36
  search: RecentSearch
37
): search is BuilderCollectionAttributes {
38
  return 'contract_address' in search
1✔
39
}
40

41
function isItemRecentSearch(search: RecentSearch): search is Item {
42
  return 'itemId' in search
1✔
43
}
44

45
export const LOCAL_STORAGE_RECENT_SEARCHES_KEY = 'marketplace_recent_searches'
2✔
46
const MAX_AMOUNT_OF_RESULTS = 5
2✔
47
const MAX_RECENT_RESULTS = 10
2✔
48

49
// Defines a custom random namespace to create UUIDs
50
const UUID_NAMESPACE = '1b671a64-40d5-491e-99b0-da01ff1f3341'
2✔
51

52
export const SearchBarDropdown = ({
2✔
53
  searchTerm,
54
  category,
55
  onSearch,
56
  fetchedCreators,
57
  isLoadingCreators,
58
  onFetchCreators,
59
  onClickOutside
60
}: SearchBarDropdownProps) => {
61
  const [searchUUID, setSearchUUID] = useState(
100✔
62
    uuidv5(searchTerm, UUID_NAMESPACE)
63
  )
64

65
  // assigns a UUID to the search term to link the events of rendering the results with the search term selected
66
  useEffect(() => {
100✔
67
    setSearchUUID(uuidv5(searchTerm, UUID_NAMESPACE))
23✔
68
  }, [searchTerm])
69

70
  const isSearchingWearables = category === NFTCategory.WEARABLE
100✔
71
  const isSearchingEmotes = category === NFTCategory.EMOTE
100✔
72

73
  const dropdownContainerRef = useRef<HTMLDivElement>(null)
100✔
74
  const [results, setResults] = useState<Results>([])
100✔
75
  const [isLoading, setIsLoading] = useState(false)
100✔
76
  const [currentSearchTab, setCurrentSearchTab] = useState<SearchTab>(
100✔
77
    category === NFTCategory.WEARABLE ? SearchTab.WEARABLES : SearchTab.EMOTES
100!
78
  )
79
  const [recentSearches, setRecentSearches] = useState<RecentSearch[]>(
100✔
80
    JSON.parse(localStorage.getItem(LOCAL_STORAGE_RECENT_SEARCHES_KEY) || '[]')
179✔
81
  )
82

83
  const handleSaveToLocalStorage = useCallback(
100✔
84
    (selection: RecentSearch) => {
85
      if (
4!
86
        !recentSearches.some(
87
          recentSearch =>
88
            JSON.stringify(recentSearch) === JSON.stringify(selection)
×
89
        )
90
      ) {
91
        const withNewSelection = [...recentSearches, selection]
4✔
92
        localStorage.setItem(
4✔
93
          LOCAL_STORAGE_RECENT_SEARCHES_KEY,
94
          JSON.stringify(withNewSelection)
95
        )
96
        setRecentSearches(withNewSelection)
4✔
97
      }
98
    },
99
    [recentSearches]
100
  )
101

102
  const handleRemoveRecentSearch = useCallback(
100✔
103
    (item: RecentSearch) => {
104
      const newRecentSearches = recentSearches.filter(
×
105
        recentSearch => recentSearch !== item
×
106
      )
107
      localStorage.setItem(
×
108
        LOCAL_STORAGE_RECENT_SEARCHES_KEY,
109
        JSON.stringify(newRecentSearches)
110
      )
111
      setRecentSearches(newRecentSearches)
×
112
    },
113
    [recentSearches]
114
  )
115

116
  const handleSeeAll = useCallback(() => {
100✔
117
    if (!searchTerm) {
3!
118
      return
×
119
    }
120
    if (
3✔
121
      currentSearchTab === SearchTab.EMOTES ||
6✔
122
      currentSearchTab === SearchTab.WEARABLES
123
    ) {
124
      onSearch({ value: searchTerm })
2✔
125
      getAnalytics().track(events.SEARCH_ALL, {
2✔
126
        tab: currentSearchTab,
127
        searchTerm,
128
        searchUUID
129
      })
130
    } else if (currentSearchTab === SearchTab.COLLECTIONS) {
1!
131
      const contractAddresses = (results as BuilderCollectionAttributes[]).map(
1✔
132
        collection => collection.contract_address
1✔
133
      )
134
      onSearch({ contractAddresses, value: '' })
1✔
135
      getAnalytics().track(events.SEARCH_ALL, {
1✔
136
        tab: currentSearchTab,
137
        searchTerm
138
      })
139
    }
140
  }, [currentSearchTab, onSearch, results, searchTerm, searchUUID])
141

142
  // handle the enter key press and trigger the See all feature
143
  useEffect(() => {
100✔
144
    const handleKeyDown = (e: KeyboardEvent) => {
58✔
145
      if (e.key === 'Enter') {
2!
146
        handleSeeAll()
2✔
147
      }
148
    }
149

150
    // Attach the event listener to the document
151
    document.addEventListener('keydown', handleKeyDown)
58✔
152

153
    // Cleanup: remove the event listener when the component is unmounted
154
    return () => {
58✔
155
      document.removeEventListener('keydown', handleKeyDown)
58✔
156
    }
157
  }, [handleSeeAll])
158

159
  useEffect(() => {
100✔
160
    let cancel = false
32✔
161
    if (searchTerm) {
32✔
162
      if (
30✔
163
        currentSearchTab === SearchTab.EMOTES ||
60✔
164
        currentSearchTab === SearchTab.WEARABLES
165
      ) {
166
        setIsLoading(true)
21✔
167
        catalogAPI
21✔
168
          .get({
169
            search: searchTerm,
170
            category: category,
171
            first: MAX_AMOUNT_OF_RESULTS
172
          })
173
          .then(response => {
174
            if (!cancel) {
21!
175
              setResults(response.data)
21✔
176
              getAnalytics().track(events.SEARCH_RESULT, {
21✔
177
                tab: currentSearchTab,
178
                searchTerm,
179
                searchUUID,
180
                items: response.data.map(item => item.id)
6✔
181
              })
182
            }
183
          })
184
          .finally(() => {
185
            if (!cancel) {
21✔
186
              setIsLoading(false)
12✔
187
            }
188
          })
189
          .catch(error => {
190
            console.error(error)
×
191
          })
192
      } else if (currentSearchTab === SearchTab.CREATORS) {
9✔
193
        onFetchCreators(searchTerm, searchUUID)
4✔
194
      } else {
195
        setIsLoading(true)
5✔
196
        builderAPI
5✔
197
          .fetchPublishedCollectionsBySearchTerm({
198
            searchTerm,
199
            limit: MAX_AMOUNT_OF_RESULTS
200
          })
201
          .then(response => {
202
            if (!cancel) {
5!
203
              setResults(response)
5✔
204
            }
205
            getAnalytics().track(events.SEARCH_RESULT, {
5✔
206
              tab: currentSearchTab,
207
              searchTerm,
208
              searchUUID,
209
              collections: response.map(
210
                collection => collection.contract_address
5✔
211
              )
212
            })
213
          })
214
          .finally(() => !cancel && setIsLoading(false))
5✔
215
          .catch(error => {
216
            console.error(error)
×
217
          })
218
      }
219
      return () => {
30✔
220
        cancel = true
30✔
221
      }
222
    }
223
  }, [
224
    category,
225
    currentSearchTab,
226
    searchTerm,
227
    isSearchingEmotes,
228
    isSearchingWearables,
229
    searchUUID,
230
    onFetchCreators
231
  ])
232

233
  // tracks the click outside the main div and close suggestions if needed
234
  useEffect(() => {
100✔
235
    const handleClickOutside = (event: MouseEvent) => {
23✔
236
      if (
14!
237
        dropdownContainerRef.current &&
28✔
238
        !dropdownContainerRef.current.contains(event.target as Node)
239
      ) {
240
        onClickOutside(event)
×
241
      }
242
    }
243
    document.addEventListener('click', handleClickOutside, true)
23✔
244
    return () => {
23✔
245
      document.removeEventListener('click', handleClickOutside, true)
23✔
246
    }
247
  }, [onClickOutside])
248

249
  const onCollectibleResultClick = useCallback(
100✔
250
    (collectible, index) => {
251
      handleSaveToLocalStorage(collectible)
1✔
252
      getAnalytics().track(events.SEARCH_RESULT_CLICKED, {
1✔
253
        searchTerm,
254
        item_id: collectible.id,
255
        search_uuid: searchUUID,
256
        item_position: index
257
      })
258
    },
259
    [handleSaveToLocalStorage, searchTerm, searchUUID]
260
  )
261

262
  const renderCollectiblesSearch = useCallback(() => {
100✔
263
    return (
34✔
264
      <>
265
        {results.length ? (
34✔
266
          <>
267
            {(results as Item[]).map((item, index) => (
268
              <CollectibleResultItemRow
7✔
269
                data-testid={`${COLLECTIBLE_DATA_TEST_ID}-${item.name}`}
270
                key={item.id}
271
                item={item}
272
                onClick={collectible =>
273
                  onCollectibleResultClick(collectible, index)
1✔
274
                }
275
              />
276
            ))}
277
            <Button
278
              className={styles.seeAllButton}
279
              inverted
280
              fluid
281
              onClick={handleSeeAll}
282
              data-testid={SEE_ALL_COLLECTIBLES_DATA_TEST_ID}
283
            >
284
              <Icon name="search" className="searchIcon" />
285
              {isSearchingEmotes
7!
286
                ? t('search_dropdown.see_all_emotes')
287
                : t('search_dropdown.see_all_wearables')}
288
            </Button>
289
          </>
290
        ) : !isLoading ? (
27!
291
          <span
292
            className={styles.searchEmpty}
293
            data-testid={NO_RESULTS_DATA_TEST_ID}
294
          >
295
            {t('search_dropdown.no_results')}
296
          </span>
297
        ) : null}
298
      </>
299
    )
300
  }, [
301
    handleSeeAll,
302
    isLoading,
303
    isSearchingEmotes,
304
    onCollectibleResultClick,
305
    results
306
  ])
307

308
  const onCreatorsResultClick = useCallback(
100✔
309
    (creator, index) => {
310
      handleSaveToLocalStorage(creator)
1✔
311
      getAnalytics().track(events.SEARCH_RESULT_CLICKED, {
1✔
312
        searchTerm,
313
        wallet_id: creator.address,
314
        search_uuid: searchUUID,
315
        item_position: index
316
      })
317
    },
318
    [handleSaveToLocalStorage, searchTerm, searchUUID]
319
  )
320

321
  const renderCreatorsSearch = useCallback(() => {
100✔
322
    return (
9✔
323
      <>
324
        {fetchedCreators
325
          .slice(0, MAX_AMOUNT_OF_RESULTS)
326
          .map((creator, index) => (
327
            <CreatorResultItemRow
9✔
328
              key={creator.address}
329
              creator={creator}
330
              onClick={creator => onCreatorsResultClick(creator, index)}
1✔
331
            />
332
          ))}
333
        {fetchedCreators.length === 0 && !isLoadingCreators ? (
18!
334
          <span className={styles.searchEmpty}>
335
            {t('search_dropdown.no_results')}
336
          </span>
337
        ) : null}
338
      </>
339
    )
340
  }, [fetchedCreators, isLoadingCreators, onCreatorsResultClick])
341

342
  const onCollectionResultClick = useCallback(
100✔
343
    (collection, index) => {
344
      onSearch({ contractAddresses: [collection.contract_address] })
2✔
345
      handleSaveToLocalStorage(collection)
2✔
346
      getAnalytics().track(events.SEARCH_RESULT_CLICKED, {
2✔
347
        searchTerm,
348
        collection_id: collection.contract_address,
349
        search_uuid: searchUUID,
350
        item_position: index
351
      })
352
    },
353
    [handleSaveToLocalStorage, onSearch, searchTerm, searchUUID]
354
  )
355

356
  const renderCollectionsSearch = useCallback(() => {
100✔
357
    return (
12✔
358
      <>
359
        {(results as BuilderCollectionAttributes[]).map((collection, index) => (
360
          <CollectionResultRow
7✔
361
            key={collection.contract_address}
362
            collection={collection}
363
            onClick={() => onCollectionResultClick(collection, index)}
2✔
364
            data-testid={`${COLLECTION_ROW_DATA_TEST_ID}-${collection.name}`}
365
          />
366
        ))}
367
        {results.length === 0 && !isLoadingCreators ? (
29✔
368
          <span className={styles.searchEmpty}>
369
            {t('search_dropdown.no_results')}
370
          </span>
371
        ) : null}
372
      </>
373
    )
374
  }, [isLoadingCreators, onCollectionResultClick, results])
375

376
  const renderLoading = useCallback(() => {
100✔
377
    switch (currentSearchTab) {
43!
378
      case SearchTab.COLLECTIONS:
379
      case SearchTab.WEARABLES:
380
      case SearchTab.EMOTES:
381
        return [...Array(5).keys()].map(index => (
43✔
382
          <SearchBarDropdownOptionSkeleton key={index} />
215✔
383
        ))
384

385
      default:
386
        return [...Array(5).keys()].map(index => (
×
387
          <SearchBarDropdownOptionSkeleton
×
388
            key={index}
389
            lines={1}
390
            shape="circle"
391
          />
392
        ))
393
    }
394
  }, [currentSearchTab])
395

396
  const renderRecentContent = useCallback(() => {
100✔
397
    if (recentSearches.length) {
1!
398
      return (
1✔
399
        <div
400
          className={styles.recentSearchesContainer}
401
          data-testid={RECENT_SEARCHES_DATA_TEST_ID}
402
        >
403
          <div className={styles.recentSearchesTitle}>
404
            {t('search_dropdown.recent')}
405
          </div>
406
          {[...recentSearches]
407
            .reverse()
408
            .slice(0, MAX_RECENT_RESULTS)
409
            .map((recentSearch, index) => (
410
              <div className={styles.recentSearchContainer} key={index}>
1✔
411
                {isCollectionRecentSearch(recentSearch) ? (
1!
412
                  <>
413
                    <img
414
                      src={clock}
415
                      alt="clock"
416
                      className={styles.recentIcon}
417
                    />
418
                    <CollectionResultRow
419
                      key={recentSearch.contract_address}
420
                      collection={recentSearch}
421
                      onClick={() =>
422
                        onSearch({
×
423
                          contractAddresses: [recentSearch.contract_address]
424
                        })
425
                      }
426
                    />
427
                  </>
428
                ) : isCreatorRecentSearch(recentSearch) ? (
1!
429
                  <>
430
                    <img
431
                      src={clock}
432
                      alt="clock"
433
                      className={styles.recentIcon}
434
                    />
435
                    <CreatorResultItemRow
436
                      key={recentSearch.address}
437
                      creator={recentSearch}
438
                      onClick={handleSaveToLocalStorage}
439
                    />
440
                  </>
441
                ) : isItemRecentSearch(recentSearch) ? (
1!
442
                  <>
443
                    <img
444
                      src={clock}
445
                      alt="clock"
446
                      className={styles.recentIcon}
447
                    />
448
                    <CollectibleResultItemRow
449
                      item={recentSearch}
450
                      onClick={handleSaveToLocalStorage}
451
                    />
452
                  </>
453
                ) : null}
454
                <Close onClick={() => handleRemoveRecentSearch(recentSearch)} />
×
455
              </div>
456
            ))}
457
        </div>
458
      )
459
    }
460
  }, [
461
    handleRemoveRecentSearch,
462
    handleSaveToLocalStorage,
463
    onSearch,
464
    recentSearches
465
  ])
466

467
  const renderContent = useCallback(() => {
100✔
468
    if (isLoading || isLoadingCreators) {
98✔
469
      return renderLoading()
43✔
470
    }
471
    switch (currentSearchTab) {
55✔
472
      case SearchTab.WEARABLES:
473
      case SearchTab.EMOTES:
474
        return renderCollectiblesSearch()
34✔
475
      case SearchTab.COLLECTIONS:
476
        return renderCollectionsSearch()
12✔
477
      case SearchTab.CREATORS:
478
        return renderCreatorsSearch()
9✔
479
    }
480
  }, [
481
    currentSearchTab,
482
    isLoading,
483
    isLoadingCreators,
484
    renderCollectiblesSearch,
485
    renderCollectionsSearch,
486
    renderCreatorsSearch,
487
    renderLoading
488
  ])
489

490
  const handleTabChange = useCallback(
100✔
491
    (newTab: SearchTab) => {
492
      setResults([])
9✔
493
      setCurrentSearchTab(newTab)
9✔
494
      setIsLoading(false)
9✔
495
    },
496
    [setCurrentSearchTab]
497
  )
498

499
  const renderTabs = useCallback(() => {
100✔
500
    return (
98✔
501
      <div className={styles.tabsContainer}>
502
        <Tabs>
503
          <Tabs.Tab
504
            active={
505
              isSearchingWearables
98!
506
                ? currentSearchTab === SearchTab.WEARABLES
507
                : currentSearchTab === SearchTab.EMOTES
508
            }
509
            onClick={() =>
510
              handleTabChange(
×
511
                isSearchingWearables ? SearchTab.WEARABLES : SearchTab.EMOTES
×
512
              )
513
            }
514
          >
515
            {isSearchingWearables ? t('menu.wearables') : t('menu.emotes')}
98!
516
          </Tabs.Tab>
517
          <Tabs.Tab
518
            active={currentSearchTab === SearchTab.CREATORS}
519
            onClick={() => handleTabChange(SearchTab.CREATORS)}
4✔
520
          >
521
            {t('search_dropdown.creators')}
522
          </Tabs.Tab>
523
          <Tabs.Tab
524
            active={currentSearchTab === SearchTab.COLLECTIONS}
525
            onClick={() => handleTabChange(SearchTab.COLLECTIONS)}
5✔
526
          >
527
            {t('search_dropdown.collections')}
528
          </Tabs.Tab>
529
        </Tabs>
530
      </div>
531
    )
532
  }, [currentSearchTab, handleTabChange, isSearchingWearables])
533

534
  return recentSearches.length || searchTerm ? (
100✔
535
    <div
536
      className={styles.searchBarDropdown}
537
      ref={dropdownContainerRef}
538
      data-testid="search-bar-dropdown"
539
    >
540
      {searchTerm ? (
99✔
541
        <>
542
          {renderTabs()}
543
          {renderContent()}
544
        </>
545
      ) : (
546
        renderRecentContent()
547
      )}
548
    </div>
549
  ) : null
550
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc