Skip to content

Commit

Permalink
Merge pull request #628 from systemli/Add-Filters-and-Sorting-for-Tic…
Browse files Browse the repository at this point in the history
…kers

✨ Add Filters and Sorting for Tickers
  • Loading branch information
0x46616c6b authored May 2, 2024
2 parents f6c63be + 708e133 commit ed19df1
Show file tree
Hide file tree
Showing 17 changed files with 551 additions and 241 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"@testing-library/dom": "^10.1.0",
"@testing-library/jest-dom": "6.4.2",
"@testing-library/react": "^15.0.5",
"@testing-library/react-hooks": "^8.0.1",
"@testing-library/user-event": "^14.5.2",
"@types/jwt-decode": "^3.1.0",
"@types/leaflet": "^1.9.12",
Expand Down
21 changes: 19 additions & 2 deletions src/api/Ticker.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { SortDirection } from '@mui/material'
import { ApiUrl, Response } from './Api'
import { User } from './User'

Expand Down Expand Up @@ -72,6 +73,14 @@ export interface TickerLocation {
lon: number
}

export interface GetTickersQueryParams {
active?: boolean
domain?: string
title?: string
order_by: string
sort: SortDirection
}

export function useTickerApi(token: string) {
const headers = {
Accept: 'application/json',
Expand Down Expand Up @@ -99,8 +108,16 @@ export function useTickerApi(token: string) {
}).then(response => response.json())
}

const getTickers = (): Promise<Response<TickersResponseData>> => {
return fetch(`${ApiUrl}/admin/tickers`, { headers: headers }).then(response => response.json())
const getTickers = (params: GetTickersQueryParams): Promise<Response<TickersResponseData>> => {
const query = new URLSearchParams()

for (const [key, value] of Object.entries(params)) {
if (value !== null && value !== undefined) {
query.append(key, String(value))
}
}

return fetch(`${ApiUrl}/admin/tickers?${query}`, { headers: headers }).then(response => response.json())
}

const getTicker = (id: number): Promise<Response<TickerResponseData>> => {
Expand Down
49 changes: 49 additions & 0 deletions src/components/ticker/TickerList.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import { AuthProvider } from '../useAuth'
import TickerList from './TickerList'
import TickerListItems from './TickerListItems'

describe('TickerList', function () {
beforeEach(() => {
fetchMock.resetMocks()
vi.useFakeTimers()
})

afterEach(() => {
vi.restoreAllMocks()
})

function setup() {
const client = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
return render(
<QueryClientProvider client={client}>
<MemoryRouter>
<AuthProvider>
<TickerList />
</AuthProvider>
</MemoryRouter>
</QueryClientProvider>
)
}

it('should render', async function () {
vi.mock('./TickerListItems', () => {
return {
__esModule: true,
default: vi.fn(() => <div></div>),
}
})

setup()

expect(TickerListItems).toHaveBeenCalledTimes(4)
})
})
116 changes: 67 additions & 49 deletions src/components/ticker/TickerList.tsx
Original file line number Diff line number Diff line change
@@ -1,76 +1,94 @@
import { FC } from 'react'
import { useTickerApi } from '../../api/Ticker'
import { FC, useEffect, useState } from 'react'
import TickerListItems from './TickerListItems'
import useAuth from '../useAuth'
import { useQuery } from '@tanstack/react-query'
import Loader from '../Loader'
import ErrorView from '../../views/ErrorView'
import { Navigate } from 'react-router'
import { Card, CardContent, Table, TableCell, TableContainer, TableHead, TableRow, Typography } from '@mui/material'
import { Table, TableCell, TableContainer, TableHead, TableRow, TableSortLabel } from '@mui/material'
import useDebounce from '../../hooks/useDebounce'
import { useSearchParams } from 'react-router-dom'
import { GetTickersQueryParams } from '../../api/Ticker'
import TickerListFilter from './TickerListFilter'

const TickerList: FC = () => {
const { token, user } = useAuth()
const { getTickers } = useTickerApi(token)
const { isLoading, error, data } = useQuery({ queryKey: ['tickers'], queryFn: getTickers })
const initialState = { order_by: 'id', sort: 'asc' } as GetTickersQueryParams
const [params, setParams] = useState<GetTickersQueryParams>(initialState)
const debouncedValue = useDebounce<GetTickersQueryParams>(params, 200, initialState)
const [, setSearchParams] = useSearchParams()

if (isLoading) {
return <Loader />
useEffect(() => {
const newSearchParams = new URLSearchParams()

if (params.order_by) newSearchParams.set('order_by', params.order_by)
if (params.sort) newSearchParams.set('sort', params.sort)
if (params.title) newSearchParams.set('title', params.title)
if (params.domain) newSearchParams.set('domain', params.domain)
if (params.active !== undefined) newSearchParams.set('active', String(params.active))
setSearchParams(newSearchParams)
}, [debouncedValue, params, setSearchParams])

const setDirection = (order_by: string) => {
if (params.order_by === order_by) {
return params.sort === 'asc' ? 'asc' : 'desc'
}

return undefined
}

const sortActive = (order_by: string) => {
return params.order_by === order_by
}

if (error || data === undefined || data.status === 'error') {
return <ErrorView queryKey={['tickers']}>Unable to fetch tickers from server.</ErrorView>
const handleSortChange = (order_by: string) => {
const direction = params.sort === 'asc' ? 'desc' : 'asc'
const sort = params.order_by === order_by ? direction : 'asc'
setParams({ ...params, order_by, sort: sort })
}

const tickers = data.data.tickers
const handleActiveChange = (_: React.MouseEvent<HTMLElement, MouseEvent>, value: unknown) => {
if (typeof value !== 'string') return

if (tickers.length === 0) {
if (user?.roles.includes('admin')) {
return (
<Card>
<CardContent>
<Typography component="h2" variant="h4">
Welcome!
</Typography>
<Typography sx={{ mt: 2 }} variant="body1">
There are no tickers yet. To start with a ticker, create one.
</Typography>
</CardContent>
</Card>
)
if (value === '') {
setParams({ ...params, active: undefined })
} else {
return (
<Card>
<CardContent>
<Typography component="h2" variant="h4">
Oh no! Something unexpected happened
</Typography>
<Typography sx={{ mt: 2 }} variant="body1">
Currently there are no tickers for you. Contact your administrator if that should be different.
</Typography>
</CardContent>
</Card>
)
setParams({ ...params, active: value === 'true' })
}
}

if (tickers.length === 1 && !user?.roles.includes('admin')) {
return <Navigate replace to={`/ticker/${tickers[0].id}`} />
const handleFilterChange = (field: string, value: string) => {
setParams({ ...params, [field]: value })
}

return (
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell align="center" size="small">
Active
<TableCell colSpan={5} sx={{ p: 1 }}>
<TickerListFilter params={params} onTitleChange={handleFilterChange} onDomainChange={handleFilterChange} onActiveChange={handleActiveChange} />
</TableCell>
</TableRow>
<TableRow>
<TableCell align="center" sortDirection={setDirection('id')}>
<TableSortLabel active={sortActive('id')} direction={setDirection('id')} onClick={() => handleSortChange('id')}>
ID
</TableSortLabel>
</TableCell>
<TableCell align="center" sortDirection={setDirection('active')}>
<TableSortLabel active={sortActive('active')} direction={setDirection('active')} onClick={() => handleSortChange('active')}>
Active
</TableSortLabel>
</TableCell>
<TableCell sortDirection={setDirection('title')}>
<TableSortLabel active={sortActive('title')} direction={setDirection('title')} onClick={() => handleSortChange('title')}>
Title
</TableSortLabel>
</TableCell>
<TableCell sortDirection={setDirection('domain')}>
<TableSortLabel active={sortActive('domain')} direction={setDirection('domain')} onClick={() => handleSortChange('domain')}>
Domain
</TableSortLabel>
</TableCell>
<TableCell>Title</TableCell>
<TableCell>Domain</TableCell>
<TableCell />
</TableRow>
</TableHead>
<TickerListItems tickers={tickers} />
<TickerListItems params={debouncedValue} />
</Table>
</TableContainer>
)
Expand Down
76 changes: 76 additions & 0 deletions src/components/ticker/TickerListFilter.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { render, screen } from '@testing-library/react'
import TickerListFilter from './TickerListFilter'
import { GetTickersQueryParams } from '../../api/Ticker'
import userEvent from '@testing-library/user-event'

describe('TickerListFilter', async function () {
it('should render', function () {
const onTitleChange = vi.fn()
const onDomainChange = vi.fn()
const onActiveChange = vi.fn()
const params = { title: '', domain: '', active: undefined } as GetTickersQueryParams

render(<TickerListFilter params={params} onTitleChange={onTitleChange} onDomainChange={onDomainChange} onActiveChange={onActiveChange} />)

expect(onTitleChange).not.toHaveBeenCalled()
expect(onDomainChange).not.toHaveBeenCalled()
expect(onActiveChange).not.toHaveBeenCalled()

expect(screen.getByLabelText('Title')).toBeInTheDocument()
expect(screen.getByLabelText('Domain')).toBeInTheDocument()
expect(screen.getByText('All')).toBeInTheDocument()
expect(screen.getByText('Active')).toBeInTheDocument()
expect(screen.getByText('Inactive')).toBeInTheDocument()

expect(screen.getByLabelText('Title')).toHaveValue('')
expect(screen.getByLabelText('Domain')).toHaveValue('')
expect(screen.getByText('All')).toHaveClass('Mui-selected')
})

it('should call onTitleChange', async function () {
const onTitleChange = vi.fn()
const onDomainChange = vi.fn()
const onActiveChange = vi.fn()
const params = { title: '', domain: '', active: undefined } as GetTickersQueryParams

render(<TickerListFilter params={params} onTitleChange={onTitleChange} onDomainChange={onDomainChange} onActiveChange={onActiveChange} />)

await userEvent.type(screen.getByLabelText('Title'), 'foo')
expect(onTitleChange).toHaveBeenCalledWith('title', 'f')
expect(onTitleChange).toHaveBeenCalledWith('title', 'o')
expect(onTitleChange).toHaveBeenCalledWith('title', 'o')
})

it('should call onDomainChange', async function () {
const onTitleChange = vi.fn()
const onDomainChange = vi.fn()
const onActiveChange = vi.fn()
const params = { title: '', domain: '', active: undefined } as GetTickersQueryParams

render(<TickerListFilter params={params} onTitleChange={onTitleChange} onDomainChange={onDomainChange} onActiveChange={onActiveChange} />)

await userEvent.type(screen.getByLabelText('Domain'), 'foo')
expect(onDomainChange).toHaveBeenCalledWith('domain', 'f')
expect(onDomainChange).toHaveBeenCalledWith('domain', 'o')
expect(onDomainChange).toHaveBeenCalledWith('domain', 'o')
})

it('should call onActiveChange', async function () {
const onTitleChange = vi.fn()
const onDomainChange = vi.fn()
const onActiveChange = vi.fn()
const params = { title: '', domain: '', active: undefined } as GetTickersQueryParams

render(<TickerListFilter params={params} onTitleChange={onTitleChange} onDomainChange={onDomainChange} onActiveChange={onActiveChange} />)

await userEvent.click(screen.getByText('Active'))
expect(onActiveChange).toHaveBeenCalledWith(expect.anything(), 'true')

await userEvent.click(screen.getByText('Inactive'))
expect(onActiveChange).toHaveBeenCalledWith(expect.anything(), 'false')

await userEvent.click(screen.getByText('All'))
expect(onActiveChange).toHaveBeenCalledWith(expect.anything(), '')
expect(onActiveChange).toHaveBeenCalledTimes(3)
})
})
50 changes: 50 additions & 0 deletions src/components/ticker/TickerListFilter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { faFilter } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { Box, Stack, TextField, ToggleButton, ToggleButtonGroup } from '@mui/material'
import { FC } from 'react'
import { GetTickersQueryParams } from '../../api/Ticker'

interface Props {
params: GetTickersQueryParams
onTitleChange: (field: string, value: string) => void
onDomainChange: (field: string, value: string) => void
onActiveChange: (e: React.MouseEvent<HTMLElement, MouseEvent>, value: unknown) => void
}

const TickerListFilter: FC<Props> = ({ params, onTitleChange, onDomainChange, onActiveChange }) => {
return (
<Stack direction="row" alignItems="center">
<Box sx={{ px: 1 }}>
<FontAwesomeIcon icon={faFilter} />
</Box>
<Box sx={{ px: 1 }}>
<TextField
label="Title"
onChange={e => onTitleChange('title', e.target.value)}
placeholder="Filter by title"
size="small"
value={params.title}
variant="outlined"
/>
</Box>
<Box sx={{ px: 1 }}>
<TextField label="Domain" onChange={e => onDomainChange('domain', e.target.value)} placeholder="Filter by domain" size="small" value={params.domain} />
</Box>
<Box sx={{ px: 1 }}>
<ToggleButtonGroup size="small" value={params.active} exclusive onChange={onActiveChange}>
<ToggleButton value="" selected={params.active === undefined}>
All
</ToggleButton>
<ToggleButton value="true" selected={params.active === true}>
Active
</ToggleButton>
<ToggleButton value="false" selected={params.active === false}>
Inactive
</ToggleButton>
</ToggleButtonGroup>
</Box>
</Stack>
)
}

export default TickerListFilter
3 changes: 3 additions & 0 deletions src/components/ticker/TickerListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ const TickerListItem: FC<Props> = ({ ticker }: Props) => {

return (
<TableRow hover style={{ cursor: 'pointer' }}>
<TableCell align="center" onClick={handleUse} padding="none" size="small">
{ticker.id}
</TableCell>
<TableCell align="center" onClick={handleUse} padding="none" size="small">
{ticker.active ? <FontAwesomeIcon icon={faCheck} /> : <FontAwesomeIcon icon={faXmark} />}
</TableCell>
Expand Down
Loading

0 comments on commit ed19df1

Please sign in to comment.