Skip to content

Commit

Permalink
Merge pull request #1 from maroparo/feat/add-filters-as-query-parameters
Browse files Browse the repository at this point in the history
feat:Added filters as query parameters to improve navigation and link sharing
  • Loading branch information
maroparo authored Jun 2, 2024
2 parents 25ee01a + e108727 commit be7ce04
Show file tree
Hide file tree
Showing 14 changed files with 187 additions and 105 deletions.
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ It uses a pre-commit hook with Husky to run type checks, linting, formatting and

#### What can be done to further improve the project:
- Add more tests to increase the coverage over the components
- Save filters as URL parameters to preserve state when navigating between routes and allow users to share the app
- Add more support for accessibility (I didn't prioritize this because the app is supposed to be used as an internal tool)
- Add some more animations to make the app more interactive

Expand Down
79 changes: 40 additions & 39 deletions src/components/Filters.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
import { useEffect, useMemo, useState } from 'react'
import { useCallback, useMemo } from 'react'

import { useDevices } from 'data/queries/useDevices.tsx'
import { Device } from 'data/types.ts'
import { usePopover } from 'hooks/usePopover.ts'
import { useToggleValues } from 'hooks/useToggleValues.ts'
import {
DeviceFilters,
displayOptions,
initialDeviceFilters,
} from 'pages/Home/hooks/useFilterDevices.ts'

import { Button } from './Button.tsx'
Expand All @@ -17,48 +15,47 @@ import { MenuItem, PopoverMenu } from './PopoverMenu.tsx'
import { SearchBox } from './SearchBox.tsx'

interface FiltersProps {
onFiltersChange: (filters: DeviceFilters) => void
onFiltersChange: (filters: Partial<DeviceFilters>) => void
filters: DeviceFilters
}

export const Filters = ({ onFiltersChange }: FiltersProps) => {
export const Filters = ({ onFiltersChange, filters }: FiltersProps) => {
const { data, isLoading } = useDevices()

const [filters, setFilters] = useState<DeviceFilters>(initialDeviceFilters)
const handleSearchTermChange = (searchTerm: string) => {
onFiltersChange({ searchTerm })
}

const handleDisplayOptionChange = (displayOption: string) => {
onFiltersChange({ displayOption })
}

useEffect(() => onFiltersChange(filters), [filters, onFiltersChange])
const toggleProductLine = useCallback(
(lineId: string) => {
const { selectedProductLines } = filters

const [selectedProductLines, toggleSelectedProductLines] = useToggleValues([])
const productLines = !selectedProductLines.includes(lineId)
? [...selectedProductLines, lineId]
: selectedProductLines.filter((id) => id !== lineId)

const filtersOptions = useMemo(() => {
return aggregateFiltersFromDevicesList(data?.devices)
}, [data])
onFiltersChange({ selectedProductLines: productLines })
},
[filters, onFiltersChange],
)

const filterOptions: MenuItem[] = useMemo(() => {
const devicesOptions: MenuItem[] = useMemo(() => {
if (!data) {
return []
}

return filtersOptions.map(({ label, value }) => ({
label,
checked: selectedProductLines.includes(value),
onClick: () => toggleSelectedProductLines(value),
}))
}, [data, filtersOptions, selectedProductLines, toggleSelectedProductLines])

useEffect(() => {
setFilters((prevFilters) => ({
...prevFilters,
selectedProductLines,
}))
}, [selectedProductLines])

const handleSearchTermChange = (searchTerm: string) => {
setFilters((prevFilters) => ({ ...prevFilters, searchTerm }))
}

const handleDisplayOptionChange = (displayOption: string) => {
setFilters((prevFilters) => ({ ...prevFilters, displayOption }))
}
return aggregateFiltersFromDevicesList(data.devices).map(
({ label, value }) => ({
label,
checked: filters.selectedProductLines.includes(value),
onClick: () => toggleProductLine(value),
}),
)
}, [data, filters.selectedProductLines, toggleProductLine])

const {
isOpen: isFilterMenuOpen,
Expand All @@ -68,13 +65,22 @@ export const Filters = ({ onFiltersChange }: FiltersProps) => {

return (
<FiltersStyled>
<SearchBox onSearchTermChange={handleSearchTermChange} />
<SearchBox
value={filters.searchTerm}
onSearchTermChange={handleSearchTermChange}
/>
<RightAdornment>
<IconRadioButtons
options={displayOptions}
onChange={handleDisplayOptionChange}
value={filters.displayOption}
/>
<PopoverMenu
title="Filter"
menuHeader="Product Line"
items={devicesOptions}
isOpen={isFilterMenuOpen}
onClose={onFilterMenuClose}
from={
<Button
title="Filter"
Expand All @@ -84,11 +90,6 @@ export const Filters = ({ onFiltersChange }: FiltersProps) => {
onClick={onFilterMenuClick}
/>
}
title="Filter"
menuHeader="Product Line"
items={filterOptions}
isOpen={isFilterMenuOpen}
onClose={onFilterMenuClose}
/>
</RightAdornment>
</FiltersStyled>
Expand Down
16 changes: 8 additions & 8 deletions src/components/IconRadioButtons.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useState } from 'react'
import { useCallback } from 'react'

import { useTheme } from 'styled-components'

Expand All @@ -14,18 +14,19 @@ import {
interface IconRadioButtonsProps {
options: IconSelectOption[]
onChange: (value: SelectOption['value']) => void
value?: string
}

export const IconRadioButtons = ({
options,
onChange,
value,
}: IconRadioButtonsProps) => {
const { color } = useTheme()
const [selectedValue, setSelectedValue] = useState(options[0].value)

const renderIcon = useCallback(
({ value, label, iconName }: IconSelectOption) => {
const isSelected = selectedValue === value
({ value: optionValue, label, iconName }: IconSelectOption) => {
const isSelected = optionValue === value

const outlineIcon = `${iconName}-outlined` as IconProps['name']
const outlineIconExists = iconExists(outlineIcon)
Expand All @@ -35,18 +36,17 @@ export const IconRadioButtons = ({
<IconButtonStyled
$selected={isSelected}
disabled={isSelected}
key={value}
key={optionValue}
title={label}
iconName={icon}
color={color.grey}
onClick={() => {
setSelectedValue(value)
onChange(value)
onChange(optionValue)
}}
/>
)
},
[color, onChange, selectedValue],
[color.grey, onChange, value],
)

return (
Expand Down
1 change: 1 addition & 0 deletions src/components/ProductCard.styled.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const ProductCardStyled = styled(Link)`
display: flex;
flex-direction: column;
height: 190px;
min-width: 215px;
border: 1px solid ${({ theme }) => theme.color.lightGrey6};
border-radius: ${({ theme }) => theme.borderRadius.m};
overflow: hidden;
Expand Down
8 changes: 4 additions & 4 deletions src/components/ProductsGrid.styled.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,19 @@ export const ProductsGridStyled = styled.div`
grid-template-columns: repeat(5, 1fr);
gap: ${spacing(3)};
@media (max-width: ${({ theme }) => theme.breakpoints.laptop}) {
@media (max-width: ${({ theme }) => theme.breakpoints.laptopL}) {
grid-template-columns: repeat(4, 1fr);
}
@media (max-width: ${({ theme }) => theme.breakpoints.tabletM}) {
@media (max-width: ${({ theme }) => theme.breakpoints.tabletL}) {
grid-template-columns: repeat(3, 1fr);
}
@media (max-width: ${({ theme }) => theme.breakpoints.tabletS}) {
@media (max-width: ${({ theme }) => theme.breakpoints.tabletM}) {
grid-template-columns: repeat(2, 1fr);
}
@media (max-width: ${({ theme }) => theme.breakpoints.mobileM}) {
@media (max-width: ${({ theme }) => theme.breakpoints.mobileL}) {
grid-template-columns: repeat(1, 1fr);
}
`
Expand Down
11 changes: 4 additions & 7 deletions src/components/SearchBox.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,21 @@
import { ChangeEvent, useState } from 'react'
import { ChangeEvent } from 'react'

import { Icon } from './Icon.tsx'
import { IconButton } from './IconButton.tsx'
import { SearchBoxStyled } from './SearchBox.styles.ts'

interface SearchBoxProps {
onSearchTermChange: (searchTerm: string) => void
value?: string
}

export const SearchBox = ({ onSearchTermChange }: SearchBoxProps) => {
const [searchTerm, setSearchTerm] = useState('')

export const SearchBox = ({ onSearchTermChange, value }: SearchBoxProps) => {
const handleSearchTermChange = (event: ChangeEvent<HTMLInputElement>) => {
const searchTerm = event.target.value
setSearchTerm(searchTerm)
onSearchTermChange(searchTerm)
}

const handleClearSearch = () => {
setSearchTerm('')
onSearchTermChange('')
}

Expand All @@ -28,7 +25,7 @@ export const SearchBox = ({ onSearchTermChange }: SearchBoxProps) => {
<input
type="text"
placeholder="Search"
value={searchTerm}
value={value}
data-testid="search-box"
onChange={handleSearchTermChange}
/>
Expand Down
21 changes: 0 additions & 21 deletions src/hooks/hooks.test.ts → src/hooks/hooks.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { useLoadMore } from './useLoadMore'
import { usePopover } from './usePopover.ts'
import { usePrevious } from './usePrevious.ts'
import { useSetPageTitle } from './useSetPageTitle.ts'
import { useToggleValues } from './useToggleValues'

describe('useLoadMore', () => {
it('should initially display the first batch of items', () => {
Expand Down Expand Up @@ -109,23 +108,3 @@ describe('useSetPageTitle', () => {
expect(document.title).toBe('Test Title')
})
})

describe('useToggleValues', () => {
it('should toggle values correctly', () => {
const { result } = renderHook(() => useToggleValues(['test1']))

expect(result.current[0]).toEqual(['test1'])

act(() => {
result.current[1]('test2')
})

expect(result.current[0]).toEqual(['test1', 'test2'])

act(() => {
result.current[1]('test1')
})

expect(result.current[0]).toEqual(['test2'])
})
})
37 changes: 37 additions & 0 deletions src/hooks/useQueryParams.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { useEffect, useMemo, useState } from 'react'

import { useSearchParams } from 'react-router-dom'

import { fromQueryParams, toQueryParams } from 'routes/utils.ts'
import { GenericObject } from 'utils/types.ts'

export function useQueryParameters<T extends GenericObject>() {
const [searchParams, setSearchParams] = useSearchParams()

const existingQueryParams = useMemo(
() => fromQueryParams<GenericObject>(new URLSearchParams(searchParams)),
[searchParams],
)

const [params, setParams] = useState(existingQueryParams)

const handleSetParams = (newParams: T) => {
const params = new URLSearchParams(searchParams)
const newParsedParams = toQueryParams(newParams)

setParams(newParsedParams)
Object.entries(newParsedParams).forEach(([key, value]) =>
params.set(key, value),
)
}

useEffect(() => {
setSearchParams(params)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [params])

return {
queryParams: existingQueryParams,
setQueryParams: handleSetParams,
}
}
21 changes: 0 additions & 21 deletions src/hooks/useToggleValues.ts

This file was deleted.

2 changes: 1 addition & 1 deletion src/pages/Home/Home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export const Home = () => {

return (
<>
<Filters onFiltersChange={onChange} />
<Filters filters={filters} onFiltersChange={onChange} />
<PageContainerStyled isLoading={isLoading}>
<ProductDisplay
devices={itemsOnDisplay}
Expand Down
Loading

0 comments on commit be7ce04

Please sign in to comment.