HistoryWrapperMobile.tsx
1 import { DocumentDownloadIcon, SearchIcon } from '@heroicons/react/outline'; 2 import { Trans } from '@lingui/macro'; 3 import { 4 Box, 5 Button, 6 CircularProgress, 7 ListItemIcon, 8 ListItemText, 9 Menu, 10 MenuItem, 11 SvgIcon, 12 Typography, 13 } from '@mui/material'; 14 import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; 15 import { ListWrapper } from 'src/components/lists/ListWrapper'; 16 import { SearchInput } from 'src/components/SearchInput'; 17 import { applyTxHistoryFilters, useTransactionHistory } from 'src/hooks/useTransactionHistory'; 18 19 import { downloadData, formatTransactionData, groupByDate } from './helpers'; 20 import { HistoryFilterMenu } from './HistoryFilterMenu'; 21 import { HistoryMobileItemLoader } from './HistoryMobileItemLoader'; 22 import TransactionMobileRowItem from './TransactionMobileRowItem'; 23 import { FilterOptions, TransactionHistoryItemUnion } from './types'; 24 25 export const HistoryWrapperMobile = () => { 26 const [searchQuery, setSearchQuery] = useState(''); 27 const [loadingDownload, setLoadingDownload] = useState(false); 28 const [filterQuery, setFilterQuery] = useState<FilterOptions[]>([]); 29 const [showSearchBar, setShowSearchBar] = useState(false); 30 const [menuAnchorEl, setMenuAnchorEl] = React.useState<null | HTMLElement>(null); 31 const [searchResetKey, setSearchResetKey] = useState(0); 32 33 const handleDownloadMenuClick = (event: React.MouseEvent<HTMLElement>) => { 34 setMenuAnchorEl(event.currentTarget); 35 }; 36 37 const handleDownloadMenuClose = () => { 38 setMenuAnchorEl(null); 39 }; 40 41 const handleCancelClick = () => { 42 setShowSearchBar(false); 43 setSearchQuery(''); 44 }; 45 46 // Create ref to exclude search box from click handler below 47 const searchBarRef = useRef<HTMLElement | null>(null); 48 49 // Close search bar if it's open, has a blank query, and the clicked item is not the search box 50 const handleClickOutside = (event: MouseEvent) => { 51 if (searchBarRef.current && !searchBarRef.current.contains(event.target as Node)) { 52 if (searchQuery === '' && showSearchBar) { 53 handleCancelClick(); 54 } 55 } 56 }; 57 58 useEffect(() => { 59 document.addEventListener('mousedown', handleClickOutside); 60 61 return () => { 62 document.removeEventListener('mousedown', handleClickOutside); 63 }; 64 // eslint-disable-next-line react-hooks/exhaustive-deps 65 }, [searchQuery, showSearchBar]); 66 67 const isFilterActive = searchQuery.length > 0 || filterQuery.length > 0; 68 69 const { 70 data: transactions, 71 isLoading, 72 fetchNextPage, 73 isFetchingNextPage, 74 fetchForDownload, 75 } = useTransactionHistory({ isFilterActive }); 76 77 const handleJsonDownload = async () => { 78 setLoadingDownload(true); 79 const data = await fetchForDownload({ searchQuery, filterQuery }); 80 const formattedData = formatTransactionData({ data, csv: false }); 81 const jsonData = JSON.stringify(formattedData, null, 2); 82 downloadData('transactions.json', jsonData, 'application/json'); 83 setLoadingDownload(false); 84 }; 85 86 const handleCsvDownload = async () => { 87 setLoadingDownload(true); 88 const data: TransactionHistoryItemUnion[] = await fetchForDownload({ 89 searchQuery, 90 filterQuery, 91 }); 92 const formattedData = formatTransactionData({ data, csv: true }); 93 94 // Getting all the unique headers 95 const headersSet = new Set<string>(); 96 formattedData.forEach((transaction: TransactionHistoryItemUnion) => { 97 Object.keys(transaction).forEach((key) => headersSet.add(key)); 98 }); 99 100 const headers: string[] = Array.from(headersSet); 101 let csvContent = headers.join(',') + '\n'; 102 103 formattedData.forEach((transaction: TransactionHistoryItemUnion) => { 104 const row: string[] = headers.map((header) => { 105 const value = transaction[header as keyof TransactionHistoryItemUnion]; 106 if (typeof value === 'object') { 107 return JSON.stringify(value) ?? ''; 108 } 109 return String(value) ?? ''; 110 }); 111 csvContent += row.join(',') + '\n'; 112 }); 113 114 downloadData('transactions.csv', csvContent, 'text/csv'); 115 setLoadingDownload(false); 116 }; 117 118 const observer = useRef<IntersectionObserver | null>(null); 119 const lastElementRef = useCallback( 120 (node: HTMLDivElement | null) => { 121 if (isLoading) return; 122 if (observer.current) observer.current.disconnect(); 123 observer.current = new IntersectionObserver((entries) => { 124 if (entries[0].isIntersecting) { 125 fetchNextPage(); 126 } 127 }); 128 if (node) observer.current.observe(node); 129 }, 130 [fetchNextPage, isLoading] 131 ); 132 133 const flatTxns = useMemo( 134 () => transactions?.pages?.flatMap((page) => page) || [], 135 [transactions] 136 ); 137 const filteredTxns = useMemo( 138 () => applyTxHistoryFilters({ searchQuery, filterQuery, txns: flatTxns }), 139 [searchQuery, filterQuery, flatTxns] 140 ); 141 const isEmpty = filteredTxns.length === 0; 142 const filterActive = searchQuery !== '' || filterQuery.length > 0; 143 144 return ( 145 <ListWrapper 146 wrapperSx={showSearchBar ? { px: 4 } : undefined} 147 titleComponent={ 148 <Box 149 ref={searchBarRef} 150 sx={{ 151 display: 'flex', 152 justifyContent: 'space-between', 153 width: '100%', 154 alignItems: 'center', 155 }} 156 > 157 {!showSearchBar && ( 158 <Typography component="div" variant="h2" sx={{ mr: 4, height: '36px' }}> 159 <Trans>Transactions</Trans> 160 </Typography> 161 )} 162 {!showSearchBar && ( 163 <Box sx={{ display: 'flex', gap: '22px' }}> 164 {loadingDownload && <CircularProgress size={20} sx={{ mr: 2 }} color="inherit" />} 165 <Box onClick={handleDownloadMenuClick} sx={{ cursor: 'pointer' }}> 166 <SvgIcon> 167 <DocumentDownloadIcon width={20} height={20} /> 168 </SvgIcon> 169 </Box> 170 <Menu 171 anchorEl={menuAnchorEl} 172 open={Boolean(menuAnchorEl)} 173 onClose={handleDownloadMenuClose} 174 > 175 <Typography variant="subheader2" color="text.secondary" sx={{ mx: 4, my: 3 }}> 176 <Trans>Export data to</Trans> 177 </Typography> 178 <MenuItem 179 onClick={() => { 180 handleJsonDownload(); 181 handleDownloadMenuClose(); 182 }} 183 > 184 <ListItemIcon> 185 <SvgIcon> 186 <DocumentDownloadIcon width={22} height={22} /> 187 </SvgIcon> 188 </ListItemIcon> 189 <ListItemText primaryTypographyProps={{ variant: 'subheader1' }}> 190 <Trans>.JSON</Trans> 191 </ListItemText> 192 </MenuItem> 193 <MenuItem 194 onClick={() => { 195 handleCsvDownload(); 196 handleDownloadMenuClose(); 197 }} 198 > 199 <ListItemIcon> 200 <SvgIcon> 201 <DocumentDownloadIcon width={22} height={22} /> 202 </SvgIcon> 203 </ListItemIcon> 204 <ListItemText primaryTypographyProps={{ variant: 'subheader1' }}> 205 <Trans>.CSV</Trans> 206 </ListItemText> 207 </MenuItem> 208 </Menu> 209 <Box onClick={() => setShowSearchBar(true)}> 210 <SvgIcon sx={{ cursor: 'pointer' }}> 211 <SearchIcon width={20} height={20} /> 212 </SvgIcon> 213 </Box> 214 </Box> 215 )} 216 {showSearchBar && ( 217 <Box sx={{ width: '100%', display: 'flex', justifyContent: 'space-between', px: 0 }}> 218 <SearchInput 219 wrapperSx={{ 220 width: '320px', 221 }} 222 placeholder="Search assets..." 223 onSearchTermChange={setSearchQuery} 224 key={searchResetKey} 225 /> 226 <Button onClick={() => handleCancelClick()}> 227 <Typography variant="buttonM"> 228 <Trans>Cancel</Trans> 229 </Typography> 230 </Button> 231 </Box> 232 )} 233 </Box> 234 } 235 > 236 <HistoryFilterMenu onFilterChange={setFilterQuery} currentFilter={filterQuery} /> 237 238 {isLoading ? ( 239 <> 240 <HistoryMobileItemLoader /> 241 <HistoryMobileItemLoader /> 242 </> 243 ) : !isEmpty ? ( 244 Object.entries(groupByDate(filteredTxns)).map(([date, txns], groupIndex) => ( 245 <React.Fragment key={groupIndex}> 246 <Typography variant="h4" color="text.primary" sx={{ ml: 4, mt: 6, mb: 2 }}> 247 {date} 248 </Typography> 249 {txns.map((transaction: TransactionHistoryItemUnion, index: number) => { 250 const isLastItem = index === txns.length - 1; 251 return ( 252 <div ref={isLastItem ? lastElementRef : null} key={index}> 253 <TransactionMobileRowItem 254 transaction={transaction as TransactionHistoryItemUnion} 255 /> 256 </div> 257 ); 258 })} 259 </React.Fragment> 260 )) 261 ) : filterActive ? ( 262 <Box 263 sx={{ 264 display: 'flex', 265 flexDirection: 'column', 266 alignItems: 'center', 267 justifyContent: 'center', 268 textAlign: 'center', 269 flex: 1, 270 maxWidth: '468px', 271 margin: '0 auto', 272 my: 24, 273 }} 274 > 275 <Typography variant="h3" color="text.primary"> 276 <Trans>Nothing found</Trans> 277 </Typography> 278 <Typography sx={{ mt: 1, mb: 4 }} variant="description" color="text.secondary"> 279 <Trans> 280 We couldn't find any transactions related to your search. Try again with a 281 different asset name, or reset filters. 282 </Trans> 283 </Typography> 284 <Button 285 variant="outlined" 286 onClick={() => { 287 setSearchQuery(''); 288 setFilterQuery([]); 289 setSearchResetKey((prevKey) => prevKey + 1); // Remount SearchInput component to clear search query 290 }} 291 > 292 Reset Filters 293 </Button> 294 </Box> 295 ) : !isFetchingNextPage ? ( 296 <Box 297 sx={{ 298 display: 'flex', 299 flexDirection: 'column', 300 alignItems: 'center', 301 justifyContent: 'center', 302 textAlign: 'center', 303 p: 4, 304 flex: 1, 305 }} 306 > 307 <Typography sx={{ my: 24 }} variant="h3" color="text.primary"> 308 <Trans>No transactions yet.</Trans> 309 </Typography> 310 </Box> 311 ) : ( 312 <></> 313 )} 314 315 <Box 316 sx={{ display: 'flex', justifyContent: 'center', mb: isFetchingNextPage ? 6 : 0, mt: 10 }} 317 > 318 {isFetchingNextPage && ( 319 <Box 320 sx={{ 321 height: 36, 322 width: 186, 323 display: 'flex', 324 alignItems: 'center', 325 justifyContent: 'center', 326 }} 327 > 328 <CircularProgress size={20} style={{ color: '#383D51' }} /> 329 </Box> 330 )} 331 </Box> 332 </ListWrapper> 333 ); 334 };