/ src / modules / history / HistoryWrapperMobile.tsx
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&apos;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  };